mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-16 20:34:39 +08:00
feat(ux): Phase 1 — onboarding, doctor, templates, navigation convergence
This commit is contained in:
parent
b4a7378888
commit
f6a3a1592a
@ -1 +0,0 @@
|
|||||||
/bin/sh: wmic: command not found
|
|
||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
<groupId>vip.mate</groupId>
|
<groupId>vip.mate</groupId>
|
||||||
<artifactId>mateclaw-server</artifactId>
|
<artifactId>mateclaw-server</artifactId>
|
||||||
<version>1.0.314</version>
|
<version>1.0.418-SNAPSHOT</version>
|
||||||
<packaging>jar</packaging>
|
<packaging>jar</packaging>
|
||||||
|
|
||||||
<name>MateClaw Server</name>
|
<name>MateClaw Server</name>
|
||||||
|
|||||||
@ -0,0 +1,38 @@
|
|||||||
|
package vip.mate.agent.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.model.TemplateDTO;
|
||||||
|
import vip.mate.agent.service.TemplateService;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 模板接口
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Tag(name = "Agent Templates")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/templates")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TemplateController {
|
||||||
|
|
||||||
|
private final TemplateService templateService;
|
||||||
|
|
||||||
|
@Operation(summary = "获取模板列表")
|
||||||
|
@GetMapping
|
||||||
|
public R<List<TemplateDTO>> list() {
|
||||||
|
return R.ok(templateService.listTemplates());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "应用模板创建Agent")
|
||||||
|
@PostMapping("/{id}/apply")
|
||||||
|
public R<AgentEntity> apply(@PathVariable String id) {
|
||||||
|
return R.ok(templateService.applyTemplate(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
package vip.mate.agent.model;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 模板 DTO
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class TemplateDTO {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private String nameZh;
|
||||||
|
private String description;
|
||||||
|
private String descriptionZh;
|
||||||
|
private String icon;
|
||||||
|
private String agentType;
|
||||||
|
private String tags;
|
||||||
|
private Integer maxIterations;
|
||||||
|
private List<WorkspaceFileTemplate> workspaceFiles;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class WorkspaceFileTemplate {
|
||||||
|
private String filename;
|
||||||
|
private String content;
|
||||||
|
private Boolean enabled;
|
||||||
|
private Integer sortOrder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package vip.mate.agent.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import vip.mate.agent.AgentService;
|
||||||
|
import vip.mate.agent.model.AgentEntity;
|
||||||
|
import vip.mate.agent.model.TemplateDTO;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.workspace.document.WorkspaceFileService;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent 模板服务
|
||||||
|
* <p>
|
||||||
|
* 扫描 classpath:templates/*.json 下的模板文件,
|
||||||
|
* 支持列出模板和应用模板创建 Agent。
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TemplateService {
|
||||||
|
|
||||||
|
private final AgentService agentService;
|
||||||
|
private final WorkspaceFileService workspaceFileService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出所有可用模板
|
||||||
|
*/
|
||||||
|
public List<TemplateDTO> listTemplates() {
|
||||||
|
List<TemplateDTO> templates = new ArrayList<>();
|
||||||
|
PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver();
|
||||||
|
|
||||||
|
try {
|
||||||
|
Resource[] resources = resolver.getResources("classpath:templates/*.json");
|
||||||
|
for (Resource resource : resources) {
|
||||||
|
try (InputStream is = resource.getInputStream()) {
|
||||||
|
TemplateDTO dto = objectMapper.readValue(is, TemplateDTO.class);
|
||||||
|
templates.add(dto);
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.warn("Failed to parse template file: {}", resource.getFilename(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.error("Failed to scan template files", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
templates.sort(Comparator.comparing(TemplateDTO::getId));
|
||||||
|
return templates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用模板创建 Agent 及其工作区文件
|
||||||
|
*
|
||||||
|
* @param templateId 模板 ID
|
||||||
|
* @return 创建的 AgentEntity
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public AgentEntity applyTemplate(String templateId) {
|
||||||
|
TemplateDTO template = listTemplates().stream()
|
||||||
|
.filter(t -> t.getId().equals(templateId))
|
||||||
|
.findFirst()
|
||||||
|
.orElseThrow(() -> new MateClawException("模板不存在: " + templateId));
|
||||||
|
|
||||||
|
// 1. 创建 Agent
|
||||||
|
AgentEntity agent = new AgentEntity();
|
||||||
|
agent.setName(template.getName());
|
||||||
|
agent.setDescription(template.getDescription());
|
||||||
|
agent.setAgentType(template.getAgentType());
|
||||||
|
agent.setIcon(template.getIcon());
|
||||||
|
agent.setTags(template.getTags());
|
||||||
|
agent.setMaxIterations(template.getMaxIterations());
|
||||||
|
AgentEntity created = agentService.createAgent(agent);
|
||||||
|
|
||||||
|
// 2. 创建工作区文件
|
||||||
|
if (template.getWorkspaceFiles() != null && !template.getWorkspaceFiles().isEmpty()) {
|
||||||
|
for (TemplateDTO.WorkspaceFileTemplate wf : template.getWorkspaceFiles()) {
|
||||||
|
workspaceFileService.saveFile(created.getId(), wf.getFilename(), wf.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 启用指定文件并按 sortOrder 排序(setPromptFiles 用列表索引作为排序值)
|
||||||
|
List<String> enabledFilenames = template.getWorkspaceFiles().stream()
|
||||||
|
.filter(wf -> Boolean.TRUE.equals(wf.getEnabled()))
|
||||||
|
.sorted((a, b) -> {
|
||||||
|
int sa = a.getSortOrder() != null ? a.getSortOrder() : 0;
|
||||||
|
int sb = b.getSortOrder() != null ? b.getSortOrder() : 0;
|
||||||
|
return Integer.compare(sa, sb);
|
||||||
|
})
|
||||||
|
.map(TemplateDTO.WorkspaceFileTemplate::getFilename)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (!enabledFilenames.isEmpty()) {
|
||||||
|
workspaceFileService.setPromptFiles(created.getId(), enabledFilenames);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,12 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
import vip.mate.common.result.R;
|
import vip.mate.common.result.R;
|
||||||
import vip.mate.config.DatabaseBootstrapRunner;
|
import vip.mate.config.DatabaseBootstrapRunner;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelDiscoveryService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setup API for first-run initialization.
|
* Setup API for first-run initialization.
|
||||||
@ -23,6 +29,9 @@ import vip.mate.config.DatabaseBootstrapRunner;
|
|||||||
public class SetupController {
|
public class SetupController {
|
||||||
|
|
||||||
private final DatabaseBootstrapRunner bootstrapRunner;
|
private final DatabaseBootstrapRunner bootstrapRunner;
|
||||||
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final ModelDiscoveryService modelDiscoveryService;
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check whether the application has been initialized.
|
* Check whether the application has been initialized.
|
||||||
@ -60,6 +69,39 @@ public class SetupController {
|
|||||||
return R.ok("Initialized with " + language);
|
return R.ok("Initialized with " + language);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Onboarding status: whether the system has a usable model configured.
|
||||||
|
* Used by the frontend to decide whether to show the onboarding wizard.
|
||||||
|
*/
|
||||||
|
@GetMapping("/onboarding-status")
|
||||||
|
public R<Map<String, Object>> getOnboardingStatus() {
|
||||||
|
boolean hasDefaultModel = false;
|
||||||
|
try {
|
||||||
|
modelConfigService.getDefaultModel();
|
||||||
|
hasDefaultModel = true;
|
||||||
|
} catch (Exception e) {
|
||||||
|
// no default model configured
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean ollamaOnline = false;
|
||||||
|
try {
|
||||||
|
ollamaOnline = modelDiscoveryService.testConnection("ollama").isSuccess();
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Ollama not available
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> configuredProviders = modelProviderService.listProviders().stream()
|
||||||
|
.filter(p -> Boolean.TRUE.equals(p.getConfigured()))
|
||||||
|
.map(p -> p.getId())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return R.ok(Map.of(
|
||||||
|
"hasDefaultModel", hasDefaultModel,
|
||||||
|
"ollamaOnline", ollamaOnline,
|
||||||
|
"configuredProviders", configuredProviders
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
public static class InitRequest {
|
public static class InitRequest {
|
||||||
private String language;
|
private String language;
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
package vip.mate.system.controller;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import vip.mate.common.result.R;
|
||||||
|
import vip.mate.system.service.SystemHealthService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System health check endpoint.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Tag(name = "System Health")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/system")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemHealthController {
|
||||||
|
|
||||||
|
private final SystemHealthService healthService;
|
||||||
|
|
||||||
|
@Operation(summary = "System health check")
|
||||||
|
@GetMapping("/health")
|
||||||
|
public R<SystemHealthService.HealthResponse> getHealth() {
|
||||||
|
return R.ok(healthService.check());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
package vip.mate.system.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import vip.mate.config.DatabaseBootstrapRunner;
|
||||||
|
import vip.mate.exception.MateClawException;
|
||||||
|
import vip.mate.llm.model.ProviderInfoDTO;
|
||||||
|
import vip.mate.llm.service.ModelConfigService;
|
||||||
|
import vip.mate.llm.service.ModelProviderService;
|
||||||
|
import vip.mate.tool.mcp.model.McpServerEntity;
|
||||||
|
import vip.mate.tool.mcp.runtime.McpClientManager;
|
||||||
|
import vip.mate.tool.mcp.runtime.McpClientManager.ConnectionResult;
|
||||||
|
import vip.mate.tool.mcp.service.McpServerService;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* System health check service.
|
||||||
|
* <p>
|
||||||
|
* Inspects default model, provider configurations, MCP server connections,
|
||||||
|
* and database initialization status.
|
||||||
|
*
|
||||||
|
* @author MateClaw Team
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemHealthService {
|
||||||
|
|
||||||
|
private final ModelConfigService modelConfigService;
|
||||||
|
private final ModelProviderService modelProviderService;
|
||||||
|
private final McpClientManager mcpClientManager;
|
||||||
|
private final McpServerService mcpServerService;
|
||||||
|
private final DatabaseBootstrapRunner bootstrapRunner;
|
||||||
|
|
||||||
|
public HealthResponse check() {
|
||||||
|
List<HealthCheck> checks = new ArrayList<>();
|
||||||
|
|
||||||
|
// 1. Default model check
|
||||||
|
checks.add(checkDefaultModel());
|
||||||
|
|
||||||
|
// 2. Provider checks (only providers that require API keys)
|
||||||
|
checks.addAll(checkProviders());
|
||||||
|
|
||||||
|
// 3. MCP server checks (enabled servers only)
|
||||||
|
checks.addAll(checkMcpServers());
|
||||||
|
|
||||||
|
// 4. Database initialization check
|
||||||
|
checks.add(checkDatabase());
|
||||||
|
|
||||||
|
// Determine overall status
|
||||||
|
String overall = "healthy";
|
||||||
|
for (HealthCheck c : checks) {
|
||||||
|
if ("error".equals(c.status())) {
|
||||||
|
overall = "error";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if ("warning".equals(c.status())) {
|
||||||
|
overall = "warning";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HealthResponse(overall, checks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private HealthCheck checkDefaultModel() {
|
||||||
|
try {
|
||||||
|
var model = modelConfigService.getDefaultModel();
|
||||||
|
return new HealthCheck(
|
||||||
|
"default-model",
|
||||||
|
"healthy",
|
||||||
|
"Default model: " + model.getName(),
|
||||||
|
null
|
||||||
|
);
|
||||||
|
} catch (MateClawException e) {
|
||||||
|
return new HealthCheck(
|
||||||
|
"default-model",
|
||||||
|
"error",
|
||||||
|
e.getMessage(),
|
||||||
|
new HealthAction("Configure Model", "/settings/models")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<HealthCheck> checkProviders() {
|
||||||
|
List<HealthCheck> results = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
List<ProviderInfoDTO> providers = modelProviderService.listProviders();
|
||||||
|
for (ProviderInfoDTO provider : providers) {
|
||||||
|
// Only check providers that require an API key
|
||||||
|
if (!Boolean.TRUE.equals(provider.getRequireApiKey())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
String providerId = provider.getId();
|
||||||
|
boolean configured = modelProviderService.isProviderConfigured(providerId);
|
||||||
|
if (!configured) {
|
||||||
|
String reason = modelProviderService.getProviderUnavailableReason(providerId);
|
||||||
|
results.add(new HealthCheck(
|
||||||
|
"provider:" + providerId,
|
||||||
|
"warning",
|
||||||
|
provider.getName() + " - " + (reason != null ? reason : "Not configured"),
|
||||||
|
new HealthAction("Configure", "/settings/models")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to check providers: {}", e.getMessage());
|
||||||
|
results.add(new HealthCheck(
|
||||||
|
"providers",
|
||||||
|
"warning",
|
||||||
|
"Unable to check providers: " + e.getMessage(),
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<HealthCheck> checkMcpServers() {
|
||||||
|
List<HealthCheck> results = new ArrayList<>();
|
||||||
|
try {
|
||||||
|
List<McpServerEntity> servers = mcpServerService.listAll();
|
||||||
|
for (McpServerEntity server : servers) {
|
||||||
|
if (!Boolean.TRUE.equals(server.getEnabled())) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ConnectionResult cr = mcpClientManager.getConnectionResult(server.getId());
|
||||||
|
if (cr == null || !cr.success()) {
|
||||||
|
String msg = server.getName() + " - "
|
||||||
|
+ (cr != null ? cr.message() : "Not connected");
|
||||||
|
results.add(new HealthCheck(
|
||||||
|
"mcp:" + server.getName(),
|
||||||
|
"warning",
|
||||||
|
msg,
|
||||||
|
new HealthAction("View Servers", "/settings/mcp-servers")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to check MCP servers: {}", e.getMessage());
|
||||||
|
results.add(new HealthCheck(
|
||||||
|
"mcp-servers",
|
||||||
|
"warning",
|
||||||
|
"Unable to check MCP servers: " + e.getMessage(),
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
private HealthCheck checkDatabase() {
|
||||||
|
if (bootstrapRunner.isInitialized()) {
|
||||||
|
return new HealthCheck("database", "healthy", "Database initialized", null);
|
||||||
|
}
|
||||||
|
return new HealthCheck(
|
||||||
|
"database",
|
||||||
|
"error",
|
||||||
|
"Database not initialized",
|
||||||
|
new HealthAction("Setup", "/setup")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Response Records ====================
|
||||||
|
|
||||||
|
public record HealthResponse(String overall, List<HealthCheck> checks) {}
|
||||||
|
|
||||||
|
public record HealthCheck(String name, String status, String message, HealthAction action) {}
|
||||||
|
|
||||||
|
public record HealthAction(String label, String route) {}
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "code-reviewer",
|
||||||
|
"name": "Code Reviewer",
|
||||||
|
"nameZh": "代码审查员",
|
||||||
|
"description": "Expert code reviewer. Reads code, identifies issues, suggests improvements.",
|
||||||
|
"descriptionZh": "代码审查专家。阅读代码、发现问题、提出改进建议。",
|
||||||
|
"icon": "🔍",
|
||||||
|
"agentType": "react",
|
||||||
|
"tags": "code,review,developer",
|
||||||
|
"maxIterations": 10,
|
||||||
|
"workspaceFiles": [
|
||||||
|
{
|
||||||
|
"filename": "AGENTS.md",
|
||||||
|
"content": "## Role\nYou are a senior code reviewer. Your job is to:\n1. Read code thoroughly before commenting\n2. Identify bugs, security issues, and performance problems\n3. Suggest concrete improvements with code examples\n4. Be direct but constructive\n\n## Review Checklist\n- Logic errors and edge cases\n- Security vulnerabilities\n- Performance bottlenecks\n- Code readability and naming\n- Error handling completeness\n- Test coverage gaps\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "SOUL.md",
|
||||||
|
"content": "# Soul\n\nBe the reviewer you'd want on your own code.\nPoint out what's wrong, but also acknowledge what's done well.\nAlways provide the fix, not just the critique.\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "PROFILE.md",
|
||||||
|
"content": "## Project Context\n\n- Language/Framework:\n- Code style guide:\n- Testing framework:\n\n_Update as you learn about the codebase._\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "MEMORY.md",
|
||||||
|
"content": "## Code Review Memory\n\n### Recurring Issues\n- (none yet)\n\n### Project Conventions\n- (none yet)\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "general-assistant",
|
||||||
|
"name": "General Assistant",
|
||||||
|
"nameZh": "通用助手",
|
||||||
|
"description": "A versatile AI assistant for everyday tasks — search, write, analyze, and more.",
|
||||||
|
"descriptionZh": "通用 AI 助手,适合日常任务——搜索、写作、分析等。",
|
||||||
|
"icon": "🤖",
|
||||||
|
"agentType": "react",
|
||||||
|
"tags": "general,assistant,default",
|
||||||
|
"maxIterations": 10,
|
||||||
|
"workspaceFiles": [
|
||||||
|
{
|
||||||
|
"filename": "AGENTS.md",
|
||||||
|
"content": "## Memory\n\nYour memory is stored in workspace files:\n- `PROFILE.md`: stable user profile and preferences\n- `MEMORY.md`: long-term facts, lessons, recurring patterns\n- `memory/YYYY-MM-DD.md`: daily notes and temporary context\n\n## Guidelines\n- Read workspace memory before answering questions about past decisions\n- Write stable facts to MEMORY.md, temporary notes to daily files\n- Be helpful, concise, and proactive\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "SOUL.md",
|
||||||
|
"content": "# Soul\n\nActually help, don't perform. Have your own opinions.\nFigure things out yourself first — use tools, then ask.\nKeep it concise when it should be, detailed when it matters.\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "PROFILE.md",
|
||||||
|
"content": "## User Profile\n\n- Name:\n- Preferences:\n- Communication style:\n\n_Update this as you learn about the user._\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "MEMORY.md",
|
||||||
|
"content": "## Long-term Memory\n\n### Stable Facts\n- (none yet)\n\n### Lessons Learned\n- (none yet)\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"id": "research-analyst",
|
||||||
|
"name": "Research Analyst",
|
||||||
|
"nameZh": "研究分析师",
|
||||||
|
"description": "Breaks down complex research tasks into steps. Uses web search and Wiki for deep analysis.",
|
||||||
|
"descriptionZh": "将复杂研究任务分解为步骤。使用网络搜索和 Wiki 进行深度分析。",
|
||||||
|
"icon": "📊",
|
||||||
|
"agentType": "plan_execute",
|
||||||
|
"tags": "research,analysis,planning",
|
||||||
|
"maxIterations": 20,
|
||||||
|
"workspaceFiles": [
|
||||||
|
{
|
||||||
|
"filename": "AGENTS.md",
|
||||||
|
"content": "## Role\nYou are a research analyst. For complex questions:\n1. Break the question into sub-questions\n2. Research each sub-question using available tools\n3. Synthesize findings into a coherent analysis\n4. Cite sources and note confidence levels\n\n## Research Process\n- Start with web search for recent information\n- Check Wiki knowledge base for existing analysis\n- Cross-reference multiple sources\n- Flag contradictions between sources\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "SOUL.md",
|
||||||
|
"content": "# Soul\n\nPrioritize accuracy over speed. Say \"I don't know\" when evidence is insufficient.\nDistinguish facts from opinions. Always cite your sources.\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "PROFILE.md",
|
||||||
|
"content": "## Research Context\n\n- Domain focus:\n- Preferred sources:\n- Output format preference:\n\n_Update as research topics become clear._\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"filename": "MEMORY.md",
|
||||||
|
"content": "## Research Memory\n\n### Key Findings\n- (none yet)\n\n### Source Quality Notes\n- (none yet)\n",
|
||||||
|
"enabled": true,
|
||||||
|
"sortOrder": 3
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mateclaw-ui",
|
"name": "mateclaw-ui",
|
||||||
"version": "1.0.314",
|
"version": "1.0.418-SNAPSHOT",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "MateClaw - Personal AI Assistant Web Console",
|
"description": "MateClaw - Personal AI Assistant Web Console",
|
||||||
|
|||||||
@ -259,6 +259,11 @@ export const oauthApi = {
|
|||||||
revoke: () => http.delete('/oauth/openai/revoke'),
|
revoke: () => http.delete('/oauth/openai/revoke'),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Setup ====================
|
||||||
|
export const setupApi = {
|
||||||
|
onboardingStatus: () => http.get('/setup/onboarding-status'),
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Settings ====================
|
// ==================== Settings ====================
|
||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => http.get('/settings'),
|
get: () => http.get('/settings'),
|
||||||
|
|||||||
@ -188,6 +188,9 @@ export default {
|
|||||||
skills: 'Skills',
|
skills: 'Skills',
|
||||||
wiki: 'Wiki KB',
|
wiki: 'Wiki KB',
|
||||||
tools: 'Tools',
|
tools: 'Tools',
|
||||||
|
core: 'Core',
|
||||||
|
connect: 'Connect',
|
||||||
|
system: 'System',
|
||||||
datasources: 'Datasources',
|
datasources: 'Datasources',
|
||||||
mcpServers: 'MCP Servers',
|
mcpServers: 'MCP Servers',
|
||||||
settingsGroup: 'Settings',
|
settingsGroup: 'Settings',
|
||||||
@ -203,6 +206,16 @@ export default {
|
|||||||
roleUser: 'User',
|
roleUser: 'User',
|
||||||
roleAdmin: 'Admin',
|
roleAdmin: 'Admin',
|
||||||
},
|
},
|
||||||
|
doctor: {
|
||||||
|
title: 'System Diagnostics',
|
||||||
|
checking: 'Checking...',
|
||||||
|
allGood: 'All systems healthy',
|
||||||
|
hasWarnings: '{count} warning(s)',
|
||||||
|
hasErrors: '{count} issue(s)',
|
||||||
|
refresh: 'Re-check',
|
||||||
|
lastChecked: 'Checked {time} ago',
|
||||||
|
diagnose: 'Diagnose',
|
||||||
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
sections: {
|
sections: {
|
||||||
@ -214,6 +227,7 @@ export default {
|
|||||||
music: 'Music Generation',
|
music: 'Music Generation',
|
||||||
video: 'Video Generation',
|
video: 'Video Generation',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
|
advanced: 'Advanced',
|
||||||
},
|
},
|
||||||
modelTitle: 'Model Management',
|
modelTitle: 'Model Management',
|
||||||
modelDesc: 'Manage provider presets and default model selection',
|
modelDesc: 'Manage provider presets and default model selection',
|
||||||
@ -1257,4 +1271,29 @@ export default {
|
|||||||
statusPending: 'Pending',
|
statusPending: 'Pending',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: 'Welcome to MateClaw',
|
||||||
|
subtitle: 'Set up your first AI model',
|
||||||
|
stepPath: 'Choose Path',
|
||||||
|
stepConfigure: 'Configure Model',
|
||||||
|
stepVerify: 'Verify',
|
||||||
|
localTitle: 'Local Model',
|
||||||
|
localDesc: 'Ollama installed? Enable local models in one click',
|
||||||
|
localDetected: 'Detected',
|
||||||
|
cloudTitle: 'Cloud API',
|
||||||
|
cloudDesc: 'Use cloud models like OpenAI, DashScope, etc.',
|
||||||
|
selectModel: 'Select Model',
|
||||||
|
setDefault: 'Set as Default',
|
||||||
|
enterApiKey: 'Enter API Key',
|
||||||
|
testConnection: 'Test Connection',
|
||||||
|
testSuccess: 'Connection Successful',
|
||||||
|
testFailed: 'Connection Failed',
|
||||||
|
saveAndContinue: 'Save & Continue',
|
||||||
|
verifyTitle: 'Verify Your Model',
|
||||||
|
verifyMessage: 'Hello! Tell me briefly about yourself.',
|
||||||
|
send: 'Send',
|
||||||
|
startUsing: 'Start Using MateClaw',
|
||||||
|
skip: 'Skip',
|
||||||
|
back: 'Back',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -183,6 +183,9 @@ export default {
|
|||||||
control: '控制台',
|
control: '控制台',
|
||||||
channels: '渠道',
|
channels: '渠道',
|
||||||
sessions: '会话',
|
sessions: '会话',
|
||||||
|
core: '核心',
|
||||||
|
connect: '连接',
|
||||||
|
system: '系统',
|
||||||
agent: '智能体',
|
agent: '智能体',
|
||||||
workspace: '工作区',
|
workspace: '工作区',
|
||||||
skills: '技能',
|
skills: '技能',
|
||||||
@ -214,6 +217,7 @@ export default {
|
|||||||
music: '音乐生成',
|
music: '音乐生成',
|
||||||
video: '视频生成',
|
video: '视频生成',
|
||||||
about: '关于',
|
about: '关于',
|
||||||
|
advanced: '高级',
|
||||||
},
|
},
|
||||||
modelTitle: '模型管理',
|
modelTitle: '模型管理',
|
||||||
modelDesc: '管理模型预设与默认模型选择',
|
modelDesc: '管理模型预设与默认模型选择',
|
||||||
@ -941,6 +945,16 @@ export default {
|
|||||||
testFailed: '连接失败,请检查配置',
|
testFailed: '连接失败,请检查配置',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
doctor: {
|
||||||
|
title: '系统诊断',
|
||||||
|
checking: '检查中...',
|
||||||
|
allGood: '所有系统正常',
|
||||||
|
hasWarnings: '{count} 个警告',
|
||||||
|
hasErrors: '{count} 个问题',
|
||||||
|
refresh: '重新检查',
|
||||||
|
lastChecked: '{time} 前检查',
|
||||||
|
diagnose: '诊断问题',
|
||||||
|
},
|
||||||
wiki: {
|
wiki: {
|
||||||
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
|
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
|
||||||
createKB: '新建知识库',
|
createKB: '新建知识库',
|
||||||
@ -1267,4 +1281,29 @@ export default {
|
|||||||
statusPending: '等待中',
|
statusPending: '等待中',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
onboarding: {
|
||||||
|
title: '欢迎使用 MateClaw',
|
||||||
|
subtitle: '配置你的第一个 AI 模型',
|
||||||
|
stepPath: '选择方式',
|
||||||
|
stepConfigure: '配置模型',
|
||||||
|
stepVerify: '验证',
|
||||||
|
localTitle: '本地模型',
|
||||||
|
localDesc: '已安装 Ollama?一键启用本地模型',
|
||||||
|
localDetected: '已检测到',
|
||||||
|
cloudTitle: '云端 API',
|
||||||
|
cloudDesc: '使用 OpenAI、DashScope 等云端模型',
|
||||||
|
selectModel: '选择模型',
|
||||||
|
setDefault: '设为默认',
|
||||||
|
enterApiKey: '输入 API Key',
|
||||||
|
testConnection: '测试连接',
|
||||||
|
testSuccess: '连接成功',
|
||||||
|
testFailed: '连接失败',
|
||||||
|
saveAndContinue: '保存并继续',
|
||||||
|
verifyTitle: '验证你的模型',
|
||||||
|
verifyMessage: '你好!简单介绍一下你自己。',
|
||||||
|
send: '发送',
|
||||||
|
startUsing: '开始使用 MateClaw',
|
||||||
|
skip: '跳过',
|
||||||
|
back: '返回',
|
||||||
|
},
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@ -8,72 +8,45 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/layout/MainLayout.vue'),
|
component: () => import('@/views/layout/MainLayout.vue'),
|
||||||
redirect: '/chat',
|
redirect: '/chat',
|
||||||
children: [
|
children: [
|
||||||
|
// ==================== Core ====================
|
||||||
{
|
{
|
||||||
path: 'chat',
|
path: 'chat',
|
||||||
name: 'Chat',
|
name: 'Chat',
|
||||||
component: () => import('@/views/ChatConsole.vue'),
|
component: () => import('@/views/ChatConsole.vue'),
|
||||||
meta: { title: 'Chat' },
|
meta: { title: 'Chat' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'channels',
|
|
||||||
name: 'Channels',
|
|
||||||
component: () => import('@/views/Channels.vue'),
|
|
||||||
meta: { title: 'Channels' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'sessions',
|
|
||||||
name: 'Sessions',
|
|
||||||
component: () => import('@/views/Sessions.vue'),
|
|
||||||
meta: { title: 'Sessions' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'workspace',
|
|
||||||
name: 'Workspace',
|
|
||||||
component: () => import('@/views/AgentWorkspace.vue'),
|
|
||||||
meta: { title: 'Workspace' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'agents',
|
path: 'agents',
|
||||||
name: 'Agents',
|
name: 'Agents',
|
||||||
component: () => import('@/views/Agents.vue'),
|
component: () => import('@/views/Agents.vue'),
|
||||||
meta: { title: 'Agents' },
|
meta: { title: 'Agents' },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'skills',
|
|
||||||
name: 'Skills',
|
|
||||||
component: () => import('@/views/SkillMarket.vue'),
|
|
||||||
meta: { title: 'Skills' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'wiki',
|
path: 'wiki',
|
||||||
name: 'Wiki',
|
name: 'Wiki',
|
||||||
component: () => import('@/views/Wiki/index.vue'),
|
component: () => import('@/views/Wiki/index.vue'),
|
||||||
meta: { title: 'Wiki' },
|
meta: { title: 'Wiki' },
|
||||||
},
|
},
|
||||||
|
// ==================== Connect ====================
|
||||||
|
{
|
||||||
|
path: 'channels',
|
||||||
|
name: 'Channels',
|
||||||
|
component: () => import('@/views/Channels.vue'),
|
||||||
|
meta: { title: 'Channels' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'skills',
|
||||||
|
name: 'Skills',
|
||||||
|
component: () => import('@/views/SkillMarket.vue'),
|
||||||
|
meta: { title: 'Skills' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'tools',
|
path: 'tools',
|
||||||
name: 'Tools',
|
name: 'Tools',
|
||||||
component: () => import('@/views/Tools.vue'),
|
component: () => import('@/views/Tools.vue'),
|
||||||
meta: { title: 'Tools' },
|
meta: { title: 'Tools' },
|
||||||
},
|
},
|
||||||
{
|
// ==================== Settings (absorbs advanced pages) ====================
|
||||||
path: 'datasources',
|
|
||||||
name: 'Datasources',
|
|
||||||
component: () => import('@/views/Datasources.vue'),
|
|
||||||
meta: { title: 'Datasources' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'mcp-servers',
|
|
||||||
name: 'McpServers',
|
|
||||||
component: () => import('@/views/McpServers.vue'),
|
|
||||||
meta: { title: 'MCP Servers' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'cron-jobs',
|
|
||||||
name: 'CronJobs',
|
|
||||||
component: () => import('@/views/CronJobs.vue'),
|
|
||||||
meta: { title: 'Cron Jobs' },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
component: () => import('@/views/Settings/Layout.vue'),
|
component: () => import('@/views/Settings/Layout.vue'),
|
||||||
@ -121,6 +94,37 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/Settings/Video/index.vue'),
|
component: () => import('@/views/Settings/Video/index.vue'),
|
||||||
meta: { title: 'Settings - Video' },
|
meta: { title: 'Settings - Video' },
|
||||||
},
|
},
|
||||||
|
// Advanced (absorbed from top-level nav)
|
||||||
|
{
|
||||||
|
path: 'workspace',
|
||||||
|
name: 'SettingsWorkspace',
|
||||||
|
component: () => import('@/views/AgentWorkspace.vue'),
|
||||||
|
meta: { title: 'Settings - Workspace' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'cron-jobs',
|
||||||
|
name: 'SettingsCronJobs',
|
||||||
|
component: () => import('@/views/CronJobs.vue'),
|
||||||
|
meta: { title: 'Settings - Cron Jobs' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'datasources',
|
||||||
|
name: 'SettingsDatasources',
|
||||||
|
component: () => import('@/views/Datasources.vue'),
|
||||||
|
meta: { title: 'Settings - Datasources' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'mcp-servers',
|
||||||
|
name: 'SettingsMcpServers',
|
||||||
|
component: () => import('@/views/McpServers.vue'),
|
||||||
|
meta: { title: 'Settings - MCP Servers' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'token-usage',
|
||||||
|
name: 'SettingsTokenUsage',
|
||||||
|
component: () => import('@/views/TokenUsage.vue'),
|
||||||
|
meta: { title: 'Settings - Token Usage' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'about',
|
path: 'about',
|
||||||
name: 'SettingsAbout',
|
name: 'SettingsAbout',
|
||||||
@ -129,6 +133,7 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// ==================== Security ====================
|
||||||
{
|
{
|
||||||
path: 'security',
|
path: 'security',
|
||||||
component: () => import('@/views/Security/Layout.vue'),
|
component: () => import('@/views/Security/Layout.vue'),
|
||||||
@ -154,12 +159,13 @@ const router = createRouter({
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
// ==================== Redirects (backward compatibility) ====================
|
||||||
path: 'token-usage',
|
{ path: 'sessions', redirect: '/chat' },
|
||||||
name: 'TokenUsage',
|
{ path: 'workspace', redirect: '/settings/workspace' },
|
||||||
component: () => import('@/views/TokenUsage.vue'),
|
{ path: 'cron-jobs', redirect: '/settings/cron-jobs' },
|
||||||
meta: { title: 'Token Usage' },
|
{ path: 'datasources', redirect: '/settings/datasources' },
|
||||||
},
|
{ path: 'mcp-servers', redirect: '/settings/mcp-servers' },
|
||||||
|
{ path: 'token-usage', redirect: '/settings/token-usage' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
133
mateclaw-ui/src/views/Doctor/DoctorDrawer.vue
Normal file
133
mateclaw-ui/src/views/Doctor/DoctorDrawer.vue
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="drawer-overlay" @click.self="emit('close')">
|
||||||
|
<div class="drawer-panel">
|
||||||
|
<div class="drawer-header">
|
||||||
|
<h2 class="drawer-title">{{ t('doctor.title') }}</h2>
|
||||||
|
<button class="drawer-close" @click="emit('close')">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Overall status banner -->
|
||||||
|
<div class="status-banner" :class="health?.overall || 'loading'">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
<span v-if="loading">{{ t('doctor.checking') }}</span>
|
||||||
|
<span v-else-if="health?.overall === 'healthy'">{{ t('doctor.allGood') }}</span>
|
||||||
|
<span v-else-if="health?.overall === 'warning'">{{ t('doctor.hasWarnings', { count: warningCount }) }}</span>
|
||||||
|
<span v-else-if="health?.overall === 'error'">{{ t('doctor.hasErrors', { count: errorCount }) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Check list -->
|
||||||
|
<div class="check-list">
|
||||||
|
<div v-for="check in health?.checks" :key="check.name" class="check-item">
|
||||||
|
<span class="check-dot" :class="check.status"></span>
|
||||||
|
<div class="check-info">
|
||||||
|
<div class="check-name">{{ check.name }}</div>
|
||||||
|
<div class="check-message">{{ check.message }}</div>
|
||||||
|
</div>
|
||||||
|
<router-link
|
||||||
|
v-if="check.action && check.status !== 'healthy'"
|
||||||
|
:to="check.action.route"
|
||||||
|
class="check-action"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
{{ check.action.label }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Refresh -->
|
||||||
|
<div class="drawer-footer">
|
||||||
|
<button class="btn-secondary" @click="fetchHealth" :disabled="loading">
|
||||||
|
{{ loading ? t('doctor.checking') : t('doctor.refresh') }}
|
||||||
|
</button>
|
||||||
|
<span v-if="lastChecked" class="last-checked">{{ t('doctor.lastChecked', { time: lastCheckedText }) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { http } from '@/api/index'
|
||||||
|
|
||||||
|
interface HealthAction { label: string; route: string }
|
||||||
|
interface HealthCheck { name: string; status: string; message: string; action?: HealthAction }
|
||||||
|
interface HealthResponse { overall: string; checks: HealthCheck[] }
|
||||||
|
|
||||||
|
const props = defineProps<{ visible: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void; (e: 'status', overall: string): void }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const health = ref<HealthResponse | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const lastChecked = ref<Date | null>(null)
|
||||||
|
|
||||||
|
const warningCount = computed(() => health.value?.checks.filter(c => c.status === 'warning').length || 0)
|
||||||
|
const errorCount = computed(() => health.value?.checks.filter(c => c.status === 'error').length || 0)
|
||||||
|
const lastCheckedText = computed(() => {
|
||||||
|
if (!lastChecked.value) return ''
|
||||||
|
const secs = Math.floor((Date.now() - lastChecked.value.getTime()) / 1000)
|
||||||
|
if (secs < 60) return `${secs}s`
|
||||||
|
return `${Math.floor(secs / 60)}m`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function fetchHealth() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await http.get('/system/health')
|
||||||
|
health.value = res.data || res
|
||||||
|
lastChecked.value = new Date()
|
||||||
|
emit('status', health.value?.overall || 'healthy')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Health check failed', e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.visible, (v) => {
|
||||||
|
if (v) fetchHealth()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.3); z-index: 1500; display: flex; justify-content: flex-end; }
|
||||||
|
.drawer-panel { width: 400px; max-width: 90vw; height: 100%; background: var(--mc-bg-elevated); border-left: 1px solid var(--mc-border); display: flex; flex-direction: column; animation: slide-in 0.2s ease; }
|
||||||
|
@keyframes slide-in { from { transform: translateX(100%); } to { transform: translateX(0); } }
|
||||||
|
|
||||||
|
.drawer-header { display: flex; align-items: center; justify-content: space-between; padding: 20px 24px; border-bottom: 1px solid var(--mc-border-light); }
|
||||||
|
.drawer-title { font-size: 16px; font-weight: 600; color: var(--mc-text-primary); margin: 0; }
|
||||||
|
.drawer-close { width: 32px; height: 32px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); display: flex; align-items: center; justify-content: center; border-radius: 6px; }
|
||||||
|
.drawer-close:hover { background: var(--mc-bg-sunken); }
|
||||||
|
|
||||||
|
.status-banner { display: flex; align-items: center; gap: 10px; padding: 12px 24px; font-size: 14px; font-weight: 500; }
|
||||||
|
.status-banner.healthy { color: var(--mc-success); background: rgba(90,138,90,0.08); }
|
||||||
|
.status-banner.warning { color: var(--mc-primary); background: var(--mc-primary-bg); }
|
||||||
|
.status-banner.error { color: var(--mc-danger); background: var(--mc-danger-bg); }
|
||||||
|
.status-banner.loading { color: var(--mc-text-secondary); background: var(--mc-bg-sunken); }
|
||||||
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
|
||||||
|
|
||||||
|
.check-list { flex: 1; overflow-y: auto; padding: 16px 24px; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.check-item { display: flex; align-items: flex-start; gap: 12px; padding: 12px; background: var(--mc-bg); border-radius: 8px; border: 1px solid var(--mc-border-light); }
|
||||||
|
.check-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
|
||||||
|
.check-dot.healthy { background: var(--mc-success); }
|
||||||
|
.check-dot.warning { background: var(--mc-primary); }
|
||||||
|
.check-dot.error { background: var(--mc-danger); }
|
||||||
|
.check-info { flex: 1; min-width: 0; }
|
||||||
|
.check-name { font-size: 13px; font-weight: 500; color: var(--mc-text-primary); }
|
||||||
|
.check-message { font-size: 12px; color: var(--mc-text-secondary); margin-top: 2px; }
|
||||||
|
.check-action { font-size: 12px; color: var(--mc-primary); text-decoration: none; white-space: nowrap; padding: 4px 10px; border: 1px solid var(--mc-primary); border-radius: 6px; flex-shrink: 0; }
|
||||||
|
.check-action:hover { background: var(--mc-primary-bg); }
|
||||||
|
|
||||||
|
.drawer-footer { padding: 16px 24px; border-top: 1px solid var(--mc-border-light); display: flex; align-items: center; gap: 12px; }
|
||||||
|
.btn-secondary { padding: 6px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 6px; font-size: 13px; cursor: pointer; }
|
||||||
|
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||||
|
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.last-checked { font-size: 11px; color: var(--mc-text-tertiary); }
|
||||||
|
</style>
|
||||||
235
mateclaw-ui/src/views/Onboarding/OnboardingWizard.vue
Normal file
235
mateclaw-ui/src/views/Onboarding/OnboardingWizard.vue
Normal file
@ -0,0 +1,235 @@
|
|||||||
|
<template>
|
||||||
|
<div class="onboarding-overlay">
|
||||||
|
<div class="onboarding-card">
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="step-indicator">
|
||||||
|
<span
|
||||||
|
v-for="s in steps"
|
||||||
|
:key="s"
|
||||||
|
class="step-dot"
|
||||||
|
:class="{ active: s === step }"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="onboarding-header">
|
||||||
|
<h2 class="onboarding-title">{{ t('onboarding.title') }}</h2>
|
||||||
|
<p class="onboarding-subtitle">{{ t('onboarding.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step labels -->
|
||||||
|
<div class="step-labels">
|
||||||
|
<span
|
||||||
|
v-for="(s, i) in steps"
|
||||||
|
:key="s"
|
||||||
|
class="step-label"
|
||||||
|
:class="{ active: s === step }"
|
||||||
|
>{{ i + 1 }}. {{ t(`onboarding.step${s.charAt(0).toUpperCase() + s.slice(1)}`) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step content -->
|
||||||
|
<div class="step-content">
|
||||||
|
<StepPathSelect
|
||||||
|
v-if="step === 'path'"
|
||||||
|
:ollama-online="ollamaOnline"
|
||||||
|
@select="onPathSelect"
|
||||||
|
/>
|
||||||
|
<StepConfigure
|
||||||
|
v-else-if="step === 'configure'"
|
||||||
|
:path="selectedPath"
|
||||||
|
@done="step = 'verify'"
|
||||||
|
/>
|
||||||
|
<StepVerify
|
||||||
|
v-else-if="step === 'verify'"
|
||||||
|
@complete="onComplete"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="onboarding-footer">
|
||||||
|
<button
|
||||||
|
v-if="step !== 'path'"
|
||||||
|
class="btn-back"
|
||||||
|
@click="goBack"
|
||||||
|
>{{ t('onboarding.back') }}</button>
|
||||||
|
<div class="footer-spacer"></div>
|
||||||
|
<button class="btn-skip" @click="onSkip">{{ t('onboarding.skip') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { http, setupApi } from '@/api/index'
|
||||||
|
import StepPathSelect from './StepPathSelect.vue'
|
||||||
|
import StepConfigure from './StepConfigure.vue'
|
||||||
|
import StepVerify from './StepVerify.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||||
|
|
||||||
|
const steps = ['path', 'configure', 'verify'] as const
|
||||||
|
const step = ref<'path' | 'configure' | 'verify'>('path')
|
||||||
|
const selectedPath = ref<'local' | 'cloud'>('local')
|
||||||
|
const ollamaOnline = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const res: any = await setupApi.onboardingStatus()
|
||||||
|
const data = res?.data || res
|
||||||
|
ollamaOnline.value = !!data?.ollamaOnline
|
||||||
|
} catch {
|
||||||
|
ollamaOnline.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onPathSelect(path: 'local' | 'cloud') {
|
||||||
|
selectedPath.value = path
|
||||||
|
step.value = 'configure'
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
if (step.value === 'verify') {
|
||||||
|
step.value = 'configure'
|
||||||
|
} else if (step.value === 'configure') {
|
||||||
|
step.value = 'path'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSkip() {
|
||||||
|
localStorage.setItem('mc-onboarding-done', 'true')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onComplete() {
|
||||||
|
localStorage.setItem('mc-onboarding-done', 'true')
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.onboarding-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2000;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-card {
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-indicator {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mc-border);
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-dot.active {
|
||||||
|
background: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-labels {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-label.active {
|
||||||
|
color: var(--mc-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-content {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-spacer {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
background: var(--mc-bg);
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-back:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-skip {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-skip:hover {
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
354
mateclaw-ui/src/views/Onboarding/StepConfigure.vue
Normal file
354
mateclaw-ui/src/views/Onboarding/StepConfigure.vue
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-configure">
|
||||||
|
<!-- Local path: Ollama model discovery -->
|
||||||
|
<template v-if="path === 'local'">
|
||||||
|
<h3 class="section-title">{{ t('onboarding.selectModel') }}</h3>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-text">{{ t('common.loading') }}</div>
|
||||||
|
<div v-else-if="models.length === 0" class="empty-text">
|
||||||
|
{{ t('onboarding.localDesc') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="model-list">
|
||||||
|
<label
|
||||||
|
v-for="model in models"
|
||||||
|
:key="model"
|
||||||
|
class="model-radio"
|
||||||
|
:class="{ selected: selectedModel === model }"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
:value="model"
|
||||||
|
v-model="selectedModel"
|
||||||
|
class="radio-input"
|
||||||
|
/>
|
||||||
|
<span class="model-name">{{ model }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!selectedModel || applying"
|
||||||
|
@click="applyLocalModel"
|
||||||
|
>{{ applying ? t('common.loading') : t('onboarding.setDefault') }}</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Cloud path: provider cards -->
|
||||||
|
<template v-else>
|
||||||
|
<h3 class="section-title">{{ t('onboarding.cloudTitle') }}</h3>
|
||||||
|
|
||||||
|
<div class="provider-cards">
|
||||||
|
<div
|
||||||
|
v-for="p in providers"
|
||||||
|
:key="p.id"
|
||||||
|
class="provider-card"
|
||||||
|
:class="{ selected: selectedProvider === p.id }"
|
||||||
|
@click="selectedProvider = p.id"
|
||||||
|
>
|
||||||
|
<span class="provider-name">{{ p.name }}</span>
|
||||||
|
<span class="provider-hint">{{ p.keyHint }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedProvider" class="api-key-section">
|
||||||
|
<label class="input-label">{{ t('onboarding.enterApiKey') }}</label>
|
||||||
|
<input
|
||||||
|
v-model="apiKey"
|
||||||
|
type="password"
|
||||||
|
class="text-input"
|
||||||
|
:placeholder="providers.find(p => p.id === selectedProvider)?.keyHint"
|
||||||
|
/>
|
||||||
|
<div class="action-row">
|
||||||
|
<button
|
||||||
|
class="btn-secondary"
|
||||||
|
:disabled="!apiKey || testing"
|
||||||
|
@click="testConnection"
|
||||||
|
>{{ testing ? t('common.loading') : t('onboarding.testConnection') }}</button>
|
||||||
|
<span v-if="testResult === 'success'" class="test-success">{{ t('onboarding.testSuccess') }}</span>
|
||||||
|
<span v-if="testResult === 'failed'" class="test-failed">{{ t('onboarding.testFailed') }}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn-primary"
|
||||||
|
:disabled="!apiKey || saving"
|
||||||
|
@click="saveCloudConfig"
|
||||||
|
>{{ saving ? t('common.loading') : t('onboarding.saveAndContinue') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { modelApi } from '@/api/index'
|
||||||
|
|
||||||
|
const props = defineProps<{ path: 'local' | 'cloud' }>()
|
||||||
|
const emit = defineEmits<{ (e: 'done'): void }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// Local path state
|
||||||
|
const loading = ref(false)
|
||||||
|
const models = ref<string[]>([])
|
||||||
|
const selectedModel = ref('')
|
||||||
|
const applying = ref(false)
|
||||||
|
|
||||||
|
// Cloud path state
|
||||||
|
const providers = [
|
||||||
|
{ id: 'openai', name: 'OpenAI', keyHint: 'sk-...' },
|
||||||
|
{ id: 'dashscope', name: 'DashScope', keyHint: 'sk-...' },
|
||||||
|
{ id: 'deepseek', name: 'DeepSeek', keyHint: 'sk-...' },
|
||||||
|
]
|
||||||
|
const selectedProvider = ref('')
|
||||||
|
const apiKey = ref('')
|
||||||
|
const testing = ref(false)
|
||||||
|
const testResult = ref<'' | 'success' | 'failed'>('')
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (props.path === 'local') {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res: any = await modelApi.discoverModels('ollama')
|
||||||
|
// DiscoverResult: { discoveredModels: [...], newModels: [...] }
|
||||||
|
const discovered = res?.data?.discoveredModels || res?.data?.newModels || res?.discoveredModels || res?.newModels || []
|
||||||
|
if (Array.isArray(discovered)) {
|
||||||
|
models.value = discovered.map((m: any) => typeof m === 'string' ? m : m.modelId || m.modelName || m.id || m.name)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ollama not available
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function applyLocalModel() {
|
||||||
|
if (!selectedModel.value) return
|
||||||
|
applying.value = true
|
||||||
|
try {
|
||||||
|
await modelApi.applyDiscoveredModels('ollama', [selectedModel.value])
|
||||||
|
await modelApi.setActive({ providerId: 'ollama', model: selectedModel.value })
|
||||||
|
emit('done')
|
||||||
|
} catch {
|
||||||
|
// Handle error silently, user can retry
|
||||||
|
} finally {
|
||||||
|
applying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
if (!selectedProvider.value || !apiKey.value) return
|
||||||
|
testing.value = true
|
||||||
|
testResult.value = ''
|
||||||
|
try {
|
||||||
|
// Save key first so test can use it
|
||||||
|
await modelApi.updateProviderConfig(selectedProvider.value, { apiKey: apiKey.value })
|
||||||
|
await modelApi.testConnection(selectedProvider.value)
|
||||||
|
testResult.value = 'success'
|
||||||
|
} catch {
|
||||||
|
testResult.value = 'failed'
|
||||||
|
} finally {
|
||||||
|
testing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCloudConfig() {
|
||||||
|
if (!selectedProvider.value || !apiKey.value) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await modelApi.updateProviderConfig(selectedProvider.value, { apiKey: apiKey.value })
|
||||||
|
emit('done')
|
||||||
|
} catch {
|
||||||
|
// Handle error silently
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-configure {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text,
|
||||||
|
.empty-text {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-radio {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-radio:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-radio.selected {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-input {
|
||||||
|
accent-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-cards {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border: 2px solid var(--mc-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-card.selected {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.provider-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-key-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-success {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-success);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-failed {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-danger);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: var(--mc-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: var(--mc-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
120
mateclaw-ui/src/views/Onboarding/StepPathSelect.vue
Normal file
120
mateclaw-ui/src/views/Onboarding/StepPathSelect.vue
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
<template>
|
||||||
|
<div class="path-select">
|
||||||
|
<div
|
||||||
|
class="path-card"
|
||||||
|
@click="emit('select', 'local')"
|
||||||
|
>
|
||||||
|
<div class="path-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<rect x="4" y="2" width="16" height="20" rx="2" />
|
||||||
|
<line x1="8" y1="6" x2="16" y2="6" />
|
||||||
|
<line x1="8" y1="10" x2="16" y2="10" />
|
||||||
|
<circle cx="12" cy="16" r="2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="path-info">
|
||||||
|
<div class="path-title-row">
|
||||||
|
<span class="path-title">{{ t('onboarding.localTitle') }}</span>
|
||||||
|
<span v-if="ollamaOnline" class="detected-badge">{{ t('onboarding.localDetected') }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="path-desc">{{ t('onboarding.localDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="path-card"
|
||||||
|
@click="emit('select', 'cloud')"
|
||||||
|
>
|
||||||
|
<div class="path-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="path-info">
|
||||||
|
<span class="path-title">{{ t('onboarding.cloudTitle') }}</span>
|
||||||
|
<p class="path-desc">{{ t('onboarding.cloudDesc') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
defineProps<{ ollamaOnline: boolean }>()
|
||||||
|
const emit = defineEmits<{ (e: 'select', path: 'local' | 'cloud'): void }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.path-select {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-card {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24px;
|
||||||
|
border: 2px solid var(--mc-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-card:hover {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
background: var(--mc-primary-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-icon {
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-card:hover .path-icon {
|
||||||
|
color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detected-badge {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--mc-success);
|
||||||
|
color: var(--mc-text-inverse);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
251
mateclaw-ui/src/views/Onboarding/StepVerify.vue
Normal file
251
mateclaw-ui/src/views/Onboarding/StepVerify.vue
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
<template>
|
||||||
|
<div class="step-verify">
|
||||||
|
<h3 class="verify-title">{{ t('onboarding.verifyTitle') }}</h3>
|
||||||
|
|
||||||
|
<!-- Mini chat area -->
|
||||||
|
<div class="chat-area">
|
||||||
|
<!-- User message -->
|
||||||
|
<div v-if="sent" class="chat-msg user-msg">
|
||||||
|
<span>{{ message }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Assistant response -->
|
||||||
|
<div v-if="response" class="chat-msg assistant-msg">
|
||||||
|
<span>{{ response }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading indicator -->
|
||||||
|
<div v-if="streaming && !response" class="chat-msg assistant-msg">
|
||||||
|
<span class="loading-dots">...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div v-if="!sent" class="input-area">
|
||||||
|
<input
|
||||||
|
v-model="message"
|
||||||
|
class="text-input"
|
||||||
|
:placeholder="t('onboarding.verifyMessage')"
|
||||||
|
@keydown.enter="sendMessage"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn-send"
|
||||||
|
:disabled="!message.trim()"
|
||||||
|
@click="sendMessage"
|
||||||
|
>{{ t('onboarding.send') }}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Complete button -->
|
||||||
|
<button
|
||||||
|
v-if="completed"
|
||||||
|
class="btn-start"
|
||||||
|
@click="emit('complete')"
|
||||||
|
>{{ t('onboarding.startUsing') }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'complete'): void }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const message = ref(t('onboarding.verifyMessage'))
|
||||||
|
const sent = ref(false)
|
||||||
|
const streaming = ref(false)
|
||||||
|
const response = ref('')
|
||||||
|
const completed = ref(false)
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
if (!message.value.trim()) return
|
||||||
|
sent.value = true
|
||||||
|
streaming.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: 'text/event-stream',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
}
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) {
|
||||||
|
headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch('/api/v1/chat/stream', {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: message.value,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!res.ok || !res.body) {
|
||||||
|
response.value = 'Error: Unable to connect'
|
||||||
|
completed.value = true
|
||||||
|
streaming.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read()
|
||||||
|
if (done) break
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true })
|
||||||
|
const lines = chunk.split('\n')
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.startsWith('data:')) {
|
||||||
|
const data = line.slice(5).trim()
|
||||||
|
if (data === '[DONE]') continue
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data)
|
||||||
|
if (parsed.content) {
|
||||||
|
response.value += parsed.content
|
||||||
|
} else if (parsed.result) {
|
||||||
|
response.value += parsed.result
|
||||||
|
} else if (typeof parsed === 'string') {
|
||||||
|
response.value += parsed
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON, treat as raw text
|
||||||
|
if (data && data !== '[DONE]') {
|
||||||
|
response.value += data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!response.value) {
|
||||||
|
response.value = 'Error: Connection failed'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
streaming.value = false
|
||||||
|
completed.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.step-verify {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: 250px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--mc-bg-sunken);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-msg {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 85%;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-msg {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: var(--mc-text-inverse);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant-msg {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: var(--mc-bg-elevated);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
border: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-dots {
|
||||||
|
animation: blink 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%, 100% { opacity: 0.3; }
|
||||||
|
50% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border: 1px solid var(--mc-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--mc-bg);
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-input:focus {
|
||||||
|
border-color: var(--mc-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: var(--mc-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:hover:not(:disabled) {
|
||||||
|
background: var(--mc-primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-send:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: var(--mc-primary);
|
||||||
|
color: var(--mc-text-inverse);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-start:hover {
|
||||||
|
background: var(--mc-primary-hover);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -2,16 +2,18 @@
|
|||||||
<div class="settings-layout">
|
<div class="settings-layout">
|
||||||
<div class="settings-nav">
|
<div class="settings-nav">
|
||||||
<h2 class="nav-title">{{ t('settings.title') }}</h2>
|
<h2 class="nav-title">{{ t('settings.title') }}</h2>
|
||||||
<router-link
|
<template v-for="section in sections" :key="section.id">
|
||||||
v-for="section in sections"
|
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
|
||||||
:key="section.id"
|
<router-link
|
||||||
:to="section.path"
|
v-else
|
||||||
class="nav-item"
|
:to="section.path"
|
||||||
:class="{ active: isActive(section.path) }"
|
class="nav-item"
|
||||||
>
|
:class="{ active: isActive(section.path) }"
|
||||||
<span class="nav-icon" v-html="section.icon"></span>
|
>
|
||||||
{{ section.label }}
|
<span class="nav-icon" v-html="section.icon"></span>
|
||||||
</router-link>
|
{{ section.label }}
|
||||||
|
</router-link>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
@ -71,6 +73,38 @@ const sections = computed(() => [
|
|||||||
label: t('settings.sections.video'),
|
label: t('settings.sections.video'),
|
||||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="23 7 16 12 23 17 23 7"/><rect x="1" y="5" width="15" height="14" rx="2" ry="2"/></svg>',
|
||||||
},
|
},
|
||||||
|
// Divider: Advanced
|
||||||
|
{ id: 'divider-advanced', path: '', label: t('settings.sections.advanced'), icon: '', isDivider: true },
|
||||||
|
{
|
||||||
|
id: 'workspace',
|
||||||
|
path: '/settings/workspace',
|
||||||
|
label: t('nav.workspace'),
|
||||||
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cron-jobs',
|
||||||
|
path: '/settings/cron-jobs',
|
||||||
|
label: t('nav.cronJobs'),
|
||||||
|
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"/><polyline points="12 6 12 12 16 14"/></svg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'datasources',
|
||||||
|
path: '/settings/datasources',
|
||||||
|
label: t('nav.datasources'),
|
||||||
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mcp-servers',
|
||||||
|
path: '/settings/mcp-servers',
|
||||||
|
label: t('nav.mcpServers'),
|
||||||
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'token-usage',
|
||||||
|
path: '/settings/token-usage',
|
||||||
|
label: t('nav.tokenUsage'),
|
||||||
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
path: '/settings/about',
|
path: '/settings/about',
|
||||||
@ -94,6 +128,7 @@ function isActive(path: string) {
|
|||||||
.nav-item + .nav-item { margin-top: 2px; }
|
.nav-item + .nav-item { margin-top: 2px; }
|
||||||
.nav-icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
.nav-icon { width: 18px; height: 18px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||||
.nav-icon :deep(svg) { width: 18px; height: 18px; display: block; }
|
.nav-icon :deep(svg) { width: 18px; height: 18px; display: block; }
|
||||||
|
.nav-divider { font-size: 11px; font-weight: 600; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.05em; padding: 16px 8px 6px; margin-top: 4px; }
|
||||||
.settings-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; }
|
.settings-content { flex: 1; overflow-y: auto; overflow-x: hidden; padding: 24px; }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
|
|||||||
@ -54,8 +54,13 @@
|
|||||||
</template>
|
</template>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 底部用户信息 -->
|
<!-- 底部 -->
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
|
<!-- Doctor 健康指示器 -->
|
||||||
|
<button class="health-indicator" :class="healthStatus" @click="showDoctor = true" :title="t('doctor.title')">
|
||||||
|
<span class="health-dot"></span>
|
||||||
|
<span v-if="!sidebarCollapsed" class="health-label">{{ t('doctor.title') }}</span>
|
||||||
|
</button>
|
||||||
<!-- 主题切换 -->
|
<!-- 主题切换 -->
|
||||||
<div class="theme-toggle-row">
|
<div class="theme-toggle-row">
|
||||||
<button
|
<button
|
||||||
@ -104,6 +109,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<router-view />
|
<router-view />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
||||||
|
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -114,12 +122,32 @@ import { useI18n } from 'vue-i18n'
|
|||||||
import { useThemeStore } from '@/stores/useThemeStore'
|
import { useThemeStore } from '@/stores/useThemeStore'
|
||||||
import { version as appVersion } from '../../../package.json'
|
import { version as appVersion } from '../../../package.json'
|
||||||
import type { ThemeMode } from '@/stores/useThemeStore'
|
import type { ThemeMode } from '@/stores/useThemeStore'
|
||||||
|
import { http, setupApi } from '@/api/index'
|
||||||
|
import OnboardingWizard from '@/views/Onboarding/OnboardingWizard.vue'
|
||||||
|
import DoctorDrawer from '@/views/Doctor/DoctorDrawer.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const sidebarCollapsed = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
|
const sidebarCollapsed = ref(localStorage.getItem('mc-sidebar-collapsed') === 'true')
|
||||||
|
const showOnboarding = ref(false)
|
||||||
|
const showDoctor = ref(false)
|
||||||
|
const healthStatus = ref('unknown')
|
||||||
|
|
||||||
|
function onHealthStatus(status: string) {
|
||||||
|
healthStatus.value = status
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHealthStatus() {
|
||||||
|
try {
|
||||||
|
const res: any = await http.get('/system/health')
|
||||||
|
const data = res?.data || res
|
||||||
|
healthStatus.value = data?.overall || 'healthy'
|
||||||
|
} catch {
|
||||||
|
healthStatus.value = 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 移动端状态
|
// 移动端状态
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
@ -131,10 +159,25 @@ function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
|||||||
if (!e.matches) mobileMenuOpen.value = false
|
if (!e.matches) mobileMenuOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||||
handleMobileChange(mobileQuery)
|
handleMobileChange(mobileQuery)
|
||||||
mobileQuery.addEventListener('change', handleMobileChange)
|
mobileQuery.addEventListener('change', handleMobileChange)
|
||||||
|
|
||||||
|
// Check onboarding status
|
||||||
|
if (!localStorage.getItem('mc-onboarding-done')) {
|
||||||
|
try {
|
||||||
|
const res: any = await setupApi.onboardingStatus()
|
||||||
|
if (res?.data && !res.data.hasDefaultModel) {
|
||||||
|
showOnboarding.value = true
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If endpoint doesn't exist yet, skip onboarding
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch initial health status for sidebar indicator
|
||||||
|
fetchHealthStatus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
@ -171,97 +214,61 @@ const themeOptions = computed<{ value: ThemeMode; label: string; icon: string }[
|
|||||||
|
|
||||||
const navGroups = computed(() => [
|
const navGroups = computed(() => [
|
||||||
{
|
{
|
||||||
key: 'chat',
|
key: 'core',
|
||||||
label: t('nav.chat'),
|
label: t('nav.core'),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: '/chat',
|
path: '/chat',
|
||||||
label: t('nav.chat'),
|
label: t('nav.chat'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/agents',
|
||||||
|
label: t('nav.agents'),
|
||||||
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/wiki',
|
||||||
|
label: t('nav.wiki'),
|
||||||
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'control',
|
key: 'connect',
|
||||||
label: t('nav.control'),
|
label: t('nav.connect'),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: '/channels',
|
path: '/channels',
|
||||||
label: t('nav.channels'),
|
label: t('nav.channels'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.18h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.73a16 16 0 0 0 6.29 6.29l1.62-1.62a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07A19.5 19.5 0 0 1 4.69 12a19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 3.6 1.18h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L7.91 8.73a16 16 0 0 0 6.29 6.29l1.62-1.62a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/sessions',
|
|
||||||
label: t('nav.sessions'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/cron-jobs',
|
|
||||||
label: t('nav.cronJobs'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'agent',
|
|
||||||
label: t('nav.agent'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
path: '/workspace',
|
|
||||||
label: t('nav.workspace'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/skills',
|
path: '/skills',
|
||||||
label: t('nav.skills'),
|
label: t('nav.skills'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/wiki',
|
|
||||||
label: t('nav.wiki'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/><line x1="8" y1="7" x2="16" y2="7"/><line x1="8" y1="11" x2="14" y2="11"/></svg>`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/tools',
|
path: '/tools',
|
||||||
label: t('nav.tools'),
|
label: t('nav.tools'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/datasources',
|
|
||||||
label: t('nav.datasources'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/mcp-servers',
|
|
||||||
label: t('nav.mcpServers'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'settings',
|
key: 'system',
|
||||||
label: t('nav.settingsGroup'),
|
label: t('nav.system'),
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
path: '/agents',
|
path: '/settings/models',
|
||||||
label: t('nav.agents'),
|
label: t('nav.settings'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 1 0-16 0"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/security',
|
path: '/security',
|
||||||
label: t('nav.security'),
|
label: t('nav.security'),
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/token-usage',
|
|
||||||
label: t('nav.tokenUsage'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/settings/models',
|
|
||||||
label: t('nav.settings'),
|
|
||||||
icon: `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/></svg>`,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
@ -474,6 +481,13 @@ function logout() {
|
|||||||
border-top: 1px solid var(--mc-border-light);
|
border-top: 1px solid var(--mc-border-light);
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
.health-indicator { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: none; background: none; border-radius: 6px; cursor: pointer; color: var(--mc-text-secondary); font-size: 12px; margin-bottom: 6px; }
|
||||||
|
.health-indicator:hover { background: var(--mc-bg-sunken); }
|
||||||
|
.health-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.health-indicator.healthy .health-dot { background: var(--mc-success); }
|
||||||
|
.health-indicator.warning .health-dot { background: var(--mc-primary); }
|
||||||
|
.health-indicator.error .health-dot { background: var(--mc-danger); }
|
||||||
|
.health-indicator.unknown .health-dot { background: var(--mc-text-tertiary); }
|
||||||
|
|
||||||
/* 主题切换 */
|
/* 主题切换 */
|
||||||
.theme-toggle-row {
|
.theme-toggle-row {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user