mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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>
|
||||
<artifactId>mateclaw-server</artifactId>
|
||||
<version>1.0.314</version>
|
||||
<version>1.0.418-SNAPSHOT</version>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<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 vip.mate.common.result.R;
|
||||
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.
|
||||
@ -23,6 +29,9 @@ import vip.mate.config.DatabaseBootstrapRunner;
|
||||
public class SetupController {
|
||||
|
||||
private final DatabaseBootstrapRunner bootstrapRunner;
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelDiscoveryService modelDiscoveryService;
|
||||
private final ModelProviderService modelProviderService;
|
||||
|
||||
/**
|
||||
* Check whether the application has been initialized.
|
||||
@ -60,6 +69,39 @@ public class SetupController {
|
||||
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
|
||||
public static class InitRequest {
|
||||
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",
|
||||
"version": "1.0.314",
|
||||
"version": "1.0.418-SNAPSHOT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "MateClaw - Personal AI Assistant Web Console",
|
||||
|
||||
@ -259,6 +259,11 @@ export const oauthApi = {
|
||||
revoke: () => http.delete('/oauth/openai/revoke'),
|
||||
}
|
||||
|
||||
// ==================== Setup ====================
|
||||
export const setupApi = {
|
||||
onboardingStatus: () => http.get('/setup/onboarding-status'),
|
||||
}
|
||||
|
||||
// ==================== Settings ====================
|
||||
export const settingsApi = {
|
||||
get: () => http.get('/settings'),
|
||||
|
||||
@ -188,6 +188,9 @@ export default {
|
||||
skills: 'Skills',
|
||||
wiki: 'Wiki KB',
|
||||
tools: 'Tools',
|
||||
core: 'Core',
|
||||
connect: 'Connect',
|
||||
system: 'System',
|
||||
datasources: 'Datasources',
|
||||
mcpServers: 'MCP Servers',
|
||||
settingsGroup: 'Settings',
|
||||
@ -203,6 +206,16 @@ export default {
|
||||
roleUser: 'User',
|
||||
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: {
|
||||
title: 'Settings',
|
||||
sections: {
|
||||
@ -214,6 +227,7 @@ export default {
|
||||
music: 'Music Generation',
|
||||
video: 'Video Generation',
|
||||
about: 'About',
|
||||
advanced: 'Advanced',
|
||||
},
|
||||
modelTitle: 'Model Management',
|
||||
modelDesc: 'Manage provider presets and default model selection',
|
||||
@ -1257,4 +1271,29 @@ export default {
|
||||
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
|
||||
|
||||
@ -183,6 +183,9 @@ export default {
|
||||
control: '控制台',
|
||||
channels: '渠道',
|
||||
sessions: '会话',
|
||||
core: '核心',
|
||||
connect: '连接',
|
||||
system: '系统',
|
||||
agent: '智能体',
|
||||
workspace: '工作区',
|
||||
skills: '技能',
|
||||
@ -214,6 +217,7 @@ export default {
|
||||
music: '音乐生成',
|
||||
video: '视频生成',
|
||||
about: '关于',
|
||||
advanced: '高级',
|
||||
},
|
||||
modelTitle: '模型管理',
|
||||
modelDesc: '管理模型预设与默认模型选择',
|
||||
@ -941,6 +945,16 @@ export default {
|
||||
testFailed: '连接失败,请检查配置',
|
||||
},
|
||||
},
|
||||
doctor: {
|
||||
title: '系统诊断',
|
||||
checking: '检查中...',
|
||||
allGood: '所有系统正常',
|
||||
hasWarnings: '{count} 个警告',
|
||||
hasErrors: '{count} 个问题',
|
||||
refresh: '重新检查',
|
||||
lastChecked: '{time} 前检查',
|
||||
diagnose: '诊断问题',
|
||||
},
|
||||
wiki: {
|
||||
desc: 'AI 驱动的结构化知识库,自动消化原始材料为 Wiki 页面',
|
||||
createKB: '新建知识库',
|
||||
@ -1267,4 +1281,29 @@ export default {
|
||||
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
|
||||
|
||||
@ -8,72 +8,45 @@ const router = createRouter({
|
||||
component: () => import('@/views/layout/MainLayout.vue'),
|
||||
redirect: '/chat',
|
||||
children: [
|
||||
// ==================== Core ====================
|
||||
{
|
||||
path: 'chat',
|
||||
name: 'Chat',
|
||||
component: () => import('@/views/ChatConsole.vue'),
|
||||
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',
|
||||
name: 'Agents',
|
||||
component: () => import('@/views/Agents.vue'),
|
||||
meta: { title: 'Agents' },
|
||||
},
|
||||
{
|
||||
path: 'skills',
|
||||
name: 'Skills',
|
||||
component: () => import('@/views/SkillMarket.vue'),
|
||||
meta: { title: 'Skills' },
|
||||
},
|
||||
{
|
||||
path: 'wiki',
|
||||
name: 'Wiki',
|
||||
component: () => import('@/views/Wiki/index.vue'),
|
||||
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',
|
||||
name: 'Tools',
|
||||
component: () => import('@/views/Tools.vue'),
|
||||
meta: { title: 'Tools' },
|
||||
},
|
||||
{
|
||||
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' },
|
||||
},
|
||||
// ==================== Settings (absorbs advanced pages) ====================
|
||||
{
|
||||
path: 'settings',
|
||||
component: () => import('@/views/Settings/Layout.vue'),
|
||||
@ -121,6 +94,37 @@ const router = createRouter({
|
||||
component: () => import('@/views/Settings/Video/index.vue'),
|
||||
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',
|
||||
name: 'SettingsAbout',
|
||||
@ -129,6 +133,7 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
// ==================== Security ====================
|
||||
{
|
||||
path: 'security',
|
||||
component: () => import('@/views/Security/Layout.vue'),
|
||||
@ -154,12 +159,13 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'token-usage',
|
||||
name: 'TokenUsage',
|
||||
component: () => import('@/views/TokenUsage.vue'),
|
||||
meta: { title: 'Token Usage' },
|
||||
},
|
||||
// ==================== Redirects (backward compatibility) ====================
|
||||
{ path: 'sessions', redirect: '/chat' },
|
||||
{ path: 'workspace', redirect: '/settings/workspace' },
|
||||
{ path: 'cron-jobs', redirect: '/settings/cron-jobs' },
|
||||
{ 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-nav">
|
||||
<h2 class="nav-title">{{ t('settings.title') }}</h2>
|
||||
<router-link
|
||||
v-for="section in sections"
|
||||
:key="section.id"
|
||||
:to="section.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(section.path) }"
|
||||
>
|
||||
<span class="nav-icon" v-html="section.icon"></span>
|
||||
{{ section.label }}
|
||||
</router-link>
|
||||
<template v-for="section in sections" :key="section.id">
|
||||
<div v-if="section.isDivider" class="nav-divider">{{ section.label }}</div>
|
||||
<router-link
|
||||
v-else
|
||||
:to="section.path"
|
||||
class="nav-item"
|
||||
:class="{ active: isActive(section.path) }"
|
||||
>
|
||||
<span class="nav-icon" v-html="section.icon"></span>
|
||||
{{ section.label }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="settings-content">
|
||||
@ -71,6 +73,38 @@ const sections = computed(() => [
|
||||
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>',
|
||||
},
|
||||
// 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',
|
||||
path: '/settings/about',
|
||||
@ -94,6 +128,7 @@ function isActive(path: string) {
|
||||
.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 :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; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@ -54,8 +54,13 @@
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- 底部用户信息 -->
|
||||
<!-- 底部 -->
|
||||
<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">
|
||||
<button
|
||||
@ -104,6 +109,9 @@
|
||||
</div>
|
||||
<router-view />
|
||||
</main>
|
||||
|
||||
<OnboardingWizard v-if="showOnboarding" @close="showOnboarding = false" />
|
||||
<DoctorDrawer :visible="showDoctor" @close="showDoctor = false" @status="onHealthStatus" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -114,12 +122,32 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useThemeStore } from '@/stores/useThemeStore'
|
||||
import { version as appVersion } from '../../../package.json'
|
||||
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 route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const themeStore = useThemeStore()
|
||||
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)
|
||||
@ -131,10 +159,25 @@ function handleMobileChange(e: MediaQueryListEvent | MediaQueryList) {
|
||||
if (!e.matches) mobileMenuOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
mobileQuery = window.matchMedia('(max-width: 768px)')
|
||||
handleMobileChange(mobileQuery)
|
||||
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(() => {
|
||||
@ -171,97 +214,61 @@ const themeOptions = computed<{ value: ThemeMode; label: string; icon: string }[
|
||||
|
||||
const navGroups = computed(() => [
|
||||
{
|
||||
key: 'chat',
|
||||
label: t('nav.chat'),
|
||||
key: 'core',
|
||||
label: t('nav.core'),
|
||||
items: [
|
||||
{
|
||||
path: '/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>`,
|
||||
},
|
||||
{
|
||||
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',
|
||||
label: t('nav.control'),
|
||||
key: 'connect',
|
||||
label: t('nav.connect'),
|
||||
items: [
|
||||
{
|
||||
path: '/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>`,
|
||||
},
|
||||
{
|
||||
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',
|
||||
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>`,
|
||||
},
|
||||
{
|
||||
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',
|
||||
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>`,
|
||||
},
|
||||
{
|
||||
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',
|
||||
label: t('nav.settingsGroup'),
|
||||
key: 'system',
|
||||
label: t('nav.system'),
|
||||
items: [
|
||||
{
|
||||
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: '/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>`,
|
||||
},
|
||||
{
|
||||
path: '/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>`,
|
||||
},
|
||||
{
|
||||
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);
|
||||
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user