mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agent): one-sentence AI employee creation wizard
Turn a single natural-language requirement into a ready-to-review employee: the model proposes name, persona, runtime type and a validated set of skills/tools/knowledge base, which the user confirms or tweaks before the agent is created. - backend: POST /api/v1/agents/generate builds a draft from the workspace's real capability catalog; every suggested tool/skill/KB is re-validated against the catalog so nothing hallucinated is offered - frontend: 3-step wizard at /agents/create reusing the existing create + binding endpoints; reusable capability picker shows selected items as compact chips with an on-demand searchable catalog
This commit is contained in:
parent
6e7c137154
commit
1522009aec
@ -12,7 +12,9 @@ import vip.mate.channel.web.Utf8SseEmitter;
|
||||
import vip.mate.agent.AgentService;
|
||||
import vip.mate.agent.AgentState;
|
||||
import vip.mate.agent.model.AgentEntity;
|
||||
import vip.mate.agent.service.AgentGenerationService;
|
||||
import vip.mate.agent.vo.AgentCapabilitiesVO;
|
||||
import vip.mate.agent.vo.AgentDraftVO;
|
||||
import vip.mate.audit.service.AuditEventService;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelCapabilityService;
|
||||
@ -51,6 +53,7 @@ public class AgentController {
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final ModelCapabilityService modelCapabilityService;
|
||||
private final SystemSettingService systemSettingService;
|
||||
private final AgentGenerationService agentGenerationService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ExecutorService sseExecutor = Executors.newCachedThreadPool();
|
||||
|
||||
@ -128,6 +131,16 @@ public class AgentController {
|
||||
}
|
||||
}
|
||||
|
||||
@Operation(summary = "根据一句话需求生成员工草稿(不落库)")
|
||||
@PostMapping("/generate")
|
||||
@RequireWorkspaceRole("member")
|
||||
public R<AgentDraftVO> generate(
|
||||
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
|
||||
@RequestBody GenerateRequest request) {
|
||||
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||
return R.ok(agentGenerationService.generateDraft(request.getRequirement(), wsId));
|
||||
}
|
||||
|
||||
@Operation(summary = "创建Agent")
|
||||
@PostMapping
|
||||
@RequireWorkspaceRole("member")
|
||||
@ -270,6 +283,11 @@ public class AgentController {
|
||||
private String conversationId = "default";
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
public static class GenerateRequest {
|
||||
private String requirement;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标资源实际归属的 workspace 与请求 header 一致。
|
||||
* 防止 "在 workspace A 鉴权,操作 workspace B 资源" 的跨域攻击。
|
||||
|
||||
@ -0,0 +1,357 @@
|
||||
package vip.mate.agent.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.messages.SystemMessage;
|
||||
import org.springframework.ai.chat.messages.UserMessage;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.ai.chat.model.ChatResponse;
|
||||
import org.springframework.ai.chat.prompt.Prompt;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.agent.AgentGraphBuilder;
|
||||
import vip.mate.agent.vo.AgentDraftVO;
|
||||
import vip.mate.exception.MateClawException;
|
||||
import vip.mate.llm.model.ModelConfigEntity;
|
||||
import vip.mate.llm.service.ModelConfigService;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.service.SkillService;
|
||||
import vip.mate.tool.model.AvailableToolDTO;
|
||||
import vip.mate.tool.service.AvailableToolService;
|
||||
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
|
||||
import vip.mate.wiki.service.WikiKnowledgeBaseService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Turns a single natural-language requirement into a ready-to-review employee
|
||||
* draft. The model is given the workspace's real capability catalog (tools,
|
||||
* skills, knowledge bases) and asked to pick from it, so the resulting draft
|
||||
* proposes a name, persona, type and a coherent set of capabilities in one
|
||||
* shot. Every suggested capability is re-validated against the catalog before
|
||||
* it leaves this service, so a hallucinated tool name or skill id never
|
||||
* reaches the wizard.
|
||||
*
|
||||
* <p>The draft is intentionally not persisted here. The wizard renders it for
|
||||
* review and edits, then commits through the existing agent-create and
|
||||
* capability-binding endpoints — reusing their tested persistence and audit
|
||||
* paths rather than duplicating them.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AgentGenerationService {
|
||||
|
||||
private final ModelConfigService modelConfigService;
|
||||
private final AgentGraphBuilder agentGraphBuilder;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AvailableToolService availableToolService;
|
||||
private final SkillService skillService;
|
||||
private final WikiKnowledgeBaseService wikiKnowledgeBaseService;
|
||||
|
||||
/** Bound the catalog we feed the model so the prompt stays compact. */
|
||||
private static final int MAX_TOOLS = 60;
|
||||
private static final int MAX_SKILLS = 40;
|
||||
private static final int MAX_KBS = 20;
|
||||
|
||||
public AgentDraftVO generateDraft(String requirement, Long workspaceId) {
|
||||
if (requirement == null || requirement.isBlank()) {
|
||||
throw new MateClawException("err.agent.generate_empty", 400,
|
||||
"Please describe the employee you want to create");
|
||||
}
|
||||
long wsId = workspaceId != null ? workspaceId : 1L;
|
||||
|
||||
ModelConfigEntity defaultModel = modelConfigService.getDefaultModel();
|
||||
if (defaultModel == null) {
|
||||
throw new MateClawException("err.agent.generate_no_model", 400,
|
||||
"No default model is configured yet");
|
||||
}
|
||||
|
||||
// Build the capability catalog the model is allowed to pick from.
|
||||
List<AvailableToolDTO> tools = bindableTools();
|
||||
List<SkillEntity> skills = workspaceSkills(wsId);
|
||||
List<WikiKnowledgeBaseEntity> kbs = workspaceKbs(wsId);
|
||||
|
||||
String systemPrompt = buildSystemPrompt();
|
||||
String userPrompt = buildUserPrompt(requirement.trim(), tools, skills, kbs);
|
||||
|
||||
String raw;
|
||||
try {
|
||||
ChatModel chatModel = agentGraphBuilder.buildRuntimeChatModel(defaultModel);
|
||||
ChatResponse response = chatModel.call(new Prompt(List.of(
|
||||
new SystemMessage(systemPrompt),
|
||||
new UserMessage(userPrompt))));
|
||||
raw = response != null && response.getResult() != null
|
||||
&& response.getResult().getOutput() != null
|
||||
? response.getResult().getOutput().getText() : null;
|
||||
} catch (Exception e) {
|
||||
log.warn("[AgentGen] LLM call failed: {}", e.getMessage());
|
||||
throw new MateClawException("err.agent.generate_failed", 500,
|
||||
"Failed to generate employee draft");
|
||||
}
|
||||
|
||||
JsonNode root = parseJson(raw);
|
||||
if (root == null || !root.isObject()) {
|
||||
throw new MateClawException("err.agent.generate_failed", 500,
|
||||
"Model returned an unexpected response");
|
||||
}
|
||||
return toDraft(root, tools, skills, kbs);
|
||||
}
|
||||
|
||||
// ==================== Catalog ====================
|
||||
|
||||
private List<AvailableToolDTO> bindableTools() {
|
||||
List<AvailableToolDTO> all;
|
||||
try {
|
||||
all = availableToolService.listAvailable();
|
||||
} catch (Exception e) {
|
||||
log.warn("[AgentGen] failed to list tools: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
List<AvailableToolDTO> out = new ArrayList<>();
|
||||
for (AvailableToolDTO t : all) {
|
||||
// Only offer tools that are currently bindable and reachable; a
|
||||
// stale MCP tool would resolve to nothing at chat time.
|
||||
if (t != null && t.isAvailable() && !t.isStale()
|
||||
&& t.getName() != null && !t.getName().isBlank()) {
|
||||
out.add(t);
|
||||
if (out.size() >= MAX_TOOLS) break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<SkillEntity> workspaceSkills(long wsId) {
|
||||
try {
|
||||
List<SkillEntity> skills = skillService.listEnabledSkills(wsId);
|
||||
return skills.size() > MAX_SKILLS ? skills.subList(0, MAX_SKILLS) : skills;
|
||||
} catch (Exception e) {
|
||||
log.warn("[AgentGen] failed to list skills: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<WikiKnowledgeBaseEntity> workspaceKbs(long wsId) {
|
||||
try {
|
||||
List<WikiKnowledgeBaseEntity> kbs = wikiKnowledgeBaseService.listByWorkspace(wsId);
|
||||
return kbs.size() > MAX_KBS ? kbs.subList(0, MAX_KBS) : kbs;
|
||||
} catch (Exception e) {
|
||||
log.warn("[AgentGen] failed to list knowledge bases: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Prompt ====================
|
||||
|
||||
private String buildSystemPrompt() {
|
||||
return """
|
||||
You are an employee (AI agent) configuration generator for an agent platform.
|
||||
Given a one-sentence requirement, output a single JSON object describing one
|
||||
ready-to-use employee. Respond in the SAME language as the requirement.
|
||||
|
||||
Output ONLY the JSON object, no prose, no markdown fences. Schema:
|
||||
{
|
||||
"name": "short display name, no instruction words",
|
||||
"icon": "a single emoji matching the role",
|
||||
"description": "one concise sentence shown on the roster card",
|
||||
"agentType": "react | plan_execute",
|
||||
"role": "short role label",
|
||||
"goal": "one short sentence on what this employee achieves",
|
||||
"systemPrompt": "the full persona prompt: who it is, how it works, constraints",
|
||||
"tags": ["1-3 short tags"],
|
||||
"recommendedQuestions": ["2-4 starter questions a user might ask first"],
|
||||
"tools": ["tool names chosen ONLY from the provided tool catalog"],
|
||||
"skillIds": ["skill ids chosen ONLY from the provided skill catalog, as strings"],
|
||||
"primaryKbId": "one knowledge base id from the catalog, or null"
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use agentType "plan_execute" only for multi-step / long-horizon work; otherwise "react".
|
||||
- Pick tools, skillIds and primaryKbId ONLY from the catalogs given below. Never invent
|
||||
names or ids. If nothing fits, return an empty array (or null for primaryKbId).
|
||||
- Prefer the smallest capability set that satisfies the requirement.
|
||||
- Skills already bundle their own tools, so do not also list a tool a chosen skill provides.
|
||||
""";
|
||||
}
|
||||
|
||||
private String buildUserPrompt(String requirement, List<AvailableToolDTO> tools,
|
||||
List<SkillEntity> skills, List<WikiKnowledgeBaseEntity> kbs) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Requirement:\n").append(requirement).append("\n\n");
|
||||
|
||||
sb.append("Tool catalog (name — description):\n");
|
||||
if (tools.isEmpty()) {
|
||||
sb.append("(none)\n");
|
||||
} else {
|
||||
for (AvailableToolDTO t : tools) {
|
||||
sb.append("- ").append(t.getName());
|
||||
if (t.getDescription() != null && !t.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(trim(t.getDescription(), 120));
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\nSkill catalog (id — name — description):\n");
|
||||
if (skills.isEmpty()) {
|
||||
sb.append("(none)\n");
|
||||
} else {
|
||||
for (SkillEntity s : skills) {
|
||||
sb.append("- ").append(s.getId()).append(" — ").append(s.getName());
|
||||
if (s.getDescription() != null && !s.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(trim(s.getDescription(), 120));
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("\nKnowledge base catalog (id — name — description):\n");
|
||||
if (kbs.isEmpty()) {
|
||||
sb.append("(none)\n");
|
||||
} else {
|
||||
for (WikiKnowledgeBaseEntity kb : kbs) {
|
||||
sb.append("- ").append(kb.getId()).append(" — ").append(kb.getName());
|
||||
if (kb.getDescription() != null && !kb.getDescription().isBlank()) {
|
||||
sb.append(" — ").append(trim(kb.getDescription(), 120));
|
||||
}
|
||||
sb.append('\n');
|
||||
}
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
// ==================== Parse + validate ====================
|
||||
|
||||
private AgentDraftVO toDraft(JsonNode root, List<AvailableToolDTO> tools,
|
||||
List<SkillEntity> skills, List<WikiKnowledgeBaseEntity> kbs) {
|
||||
String name = text(root, "name");
|
||||
if (name.isBlank()) {
|
||||
name = "New employee";
|
||||
}
|
||||
String agentType = text(root, "agentType");
|
||||
if (!"plan_execute".equals(agentType)) {
|
||||
agentType = "react";
|
||||
}
|
||||
|
||||
return AgentDraftVO.builder()
|
||||
.name(trim(name, 60))
|
||||
.icon(firstEmoji(text(root, "icon")))
|
||||
.description(trim(text(root, "description"), 200))
|
||||
.agentType(agentType)
|
||||
.role(trim(text(root, "role"), 60))
|
||||
.goal(trim(text(root, "goal"), 120))
|
||||
.systemPrompt(text(root, "systemPrompt"))
|
||||
.tags(stringList(root.get("tags"), 5))
|
||||
.recommendedQuestions(stringList(root.get("recommendedQuestions"), 4))
|
||||
.tools(validTools(root.get("tools"), tools))
|
||||
.skillIds(validSkillIds(root.get("skillIds"), skills))
|
||||
.primaryKbId(validKbId(root.get("primaryKbId"), kbs))
|
||||
.build();
|
||||
}
|
||||
|
||||
private List<String> validTools(JsonNode node, List<AvailableToolDTO> catalog) {
|
||||
Set<String> allowed = new LinkedHashSet<>();
|
||||
for (AvailableToolDTO t : catalog) allowed.add(t.getName());
|
||||
List<String> out = new ArrayList<>();
|
||||
if (node != null && node.isArray()) {
|
||||
for (JsonNode n : node) {
|
||||
String v = n.asText("");
|
||||
if (allowed.contains(v) && !out.contains(v)) out.add(v);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private List<Long> validSkillIds(JsonNode node, List<SkillEntity> catalog) {
|
||||
Map<Long, Boolean> allowed = new LinkedHashMap<>();
|
||||
for (SkillEntity s : catalog) allowed.put(s.getId(), Boolean.TRUE);
|
||||
List<Long> out = new ArrayList<>();
|
||||
if (node != null && node.isArray()) {
|
||||
for (JsonNode n : node) {
|
||||
Long id = asLong(n);
|
||||
if (id != null && allowed.containsKey(id) && !out.contains(id)) out.add(id);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Long validKbId(JsonNode node, List<WikiKnowledgeBaseEntity> catalog) {
|
||||
Long id = asLong(node);
|
||||
if (id == null) return null;
|
||||
for (WikiKnowledgeBaseEntity kb : catalog) {
|
||||
if (kb.getId().equals(id)) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private JsonNode parseJson(String response) {
|
||||
if (response == null || response.isBlank()) return null;
|
||||
String cleaned = response.trim();
|
||||
if (cleaned.startsWith("```json")) cleaned = cleaned.substring(7);
|
||||
else if (cleaned.startsWith("```")) cleaned = cleaned.substring(3);
|
||||
if (cleaned.endsWith("```")) cleaned = cleaned.substring(0, cleaned.length() - 3);
|
||||
cleaned = cleaned.trim();
|
||||
try {
|
||||
return objectMapper.readTree(cleaned);
|
||||
} catch (Exception e) {
|
||||
log.debug("[AgentGen] JSON parse failed: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Accept both numeric and textual ids — textual is preferred to preserve precision. */
|
||||
private Long asLong(JsonNode node) {
|
||||
if (node == null || node.isNull()) return null;
|
||||
try {
|
||||
if (node.isTextual()) {
|
||||
String v = node.asText().trim();
|
||||
return v.isEmpty() ? null : Long.parseLong(v);
|
||||
}
|
||||
if (node.isNumber()) return node.asLong();
|
||||
} catch (NumberFormatException ignored) {
|
||||
// fall through
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String text(JsonNode root, String field) {
|
||||
JsonNode n = root.get(field);
|
||||
return n == null || n.isNull() ? "" : n.asText("").trim();
|
||||
}
|
||||
|
||||
private static List<String> stringList(JsonNode node, int max) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (node != null && node.isArray()) {
|
||||
for (JsonNode n : node) {
|
||||
String v = n.asText("").trim();
|
||||
if (!v.isEmpty() && !out.contains(v)) {
|
||||
out.add(v);
|
||||
if (out.size() >= max) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static String trim(String s, int max) {
|
||||
if (s == null) return "";
|
||||
String t = s.trim();
|
||||
return t.length() > max ? t.substring(0, max) : t;
|
||||
}
|
||||
|
||||
/** Keep only the first emoji-ish glyph so the icon column never holds a sentence. */
|
||||
private static String firstEmoji(String s) {
|
||||
if (s == null || s.isBlank()) return "🤖";
|
||||
String t = s.trim();
|
||||
int end = t.offsetByCodePoints(0, Math.min(t.codePointCount(0, t.length()), 1));
|
||||
return t.substring(0, end);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package vip.mate.agent.vo;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* An AI-generated employee draft produced from a single natural-language
|
||||
* requirement. The draft is never persisted on its own — the create wizard
|
||||
* shows it for review, lets the user tweak any field, then commits it through
|
||||
* the normal agent-create and capability-binding endpoints.
|
||||
*
|
||||
* <p>Every suggested capability ({@link #tools}, {@link #skillIds},
|
||||
* {@link #primaryKbId}) is validated against the workspace catalog before the
|
||||
* draft is returned, so the wizard never offers a tool name or knowledge base
|
||||
* that does not actually exist.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
public class AgentDraftVO {
|
||||
|
||||
/** Display name for the new employee. */
|
||||
private String name;
|
||||
|
||||
/** Emoji icon chosen to match the role. */
|
||||
private String icon;
|
||||
|
||||
/** One-line description shown on the roster card. */
|
||||
private String description;
|
||||
|
||||
/** Runtime kind: {@code react} or {@code plan_execute}. */
|
||||
private String agentType;
|
||||
|
||||
/** Assembled persona / system prompt, editable before commit. */
|
||||
private String systemPrompt;
|
||||
|
||||
/** Short role label, used for the card tagline preview. */
|
||||
private String role;
|
||||
|
||||
/** Short goal statement, used for the card tagline preview. */
|
||||
private String goal;
|
||||
|
||||
/** Suggested tags. */
|
||||
private List<String> tags;
|
||||
|
||||
/** A few starter questions to seed the first conversation. */
|
||||
private List<String> recommendedQuestions;
|
||||
|
||||
/**
|
||||
* Tool names to bind, drawn from the workspace's available tool catalog
|
||||
* (built-in and MCP). Hallucinated names are dropped during validation.
|
||||
*/
|
||||
private List<String> tools;
|
||||
|
||||
/** Skill ids to bind, validated against the workspace's enabled skills. */
|
||||
@JsonSerialize(contentUsing = ToStringSerializer.class)
|
||||
private List<Long> skillIds;
|
||||
|
||||
/** Primary knowledge base id to attach, or null when none fits. */
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
private Long primaryKbId;
|
||||
}
|
||||
@ -109,6 +109,8 @@ export const agentApi = {
|
||||
list: (params?: { enabled?: boolean }) => http.get('/agents', { params }),
|
||||
get: (id: string | number) => http.get(`/agents/${id}`),
|
||||
create: (data: any) => http.post('/agents', data),
|
||||
/** Generate a reviewable employee draft from a one-sentence requirement (no persistence). */
|
||||
generate: (requirement: string) => http.post('/agents/generate', { requirement }),
|
||||
update: (id: string | number, data: any) => http.put(`/agents/${id}`, data),
|
||||
delete: (id: string | number) => http.delete(`/agents/${id}`),
|
||||
chat: (id: string | number, data: any) => http.post(`/agents/${id}/chat`, data),
|
||||
|
||||
140
mateclaw-ui/src/components/agent/WizardCapabilityPicker.vue
Normal file
140
mateclaw-ui/src/components/agent/WizardCapabilityPicker.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<div class="cap-card">
|
||||
<div class="cap-head">
|
||||
<svg v-if="kind === 'skills'" class="cap-head-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 3l9 5-9 5-9-5 9-5z"/><path d="M3 13l9 5 9-5"/></svg>
|
||||
<svg v-else class="cap-head-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="8" x2="20" y2="8"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="9" cy="8" r="2"/><circle cx="15" cy="16" r="2"/></svg>
|
||||
<span class="cap-title">{{ title }}</span>
|
||||
<span v-if="badge" class="cap-badge">{{ badge }}</span>
|
||||
<button v-if="items.length" type="button" class="cap-add" @click="open = !open">
|
||||
{{ open ? collapseLabel : addLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- The answer first: selected capabilities as compact removable chips. -->
|
||||
<div v-if="selectedItems.length" class="cap-chips">
|
||||
<span v-for="it in selectedItems" :key="it.key" class="cap-chip" :title="it.desc || it.name">
|
||||
{{ it.name }}
|
||||
<button type="button" class="cap-chip-x" :aria-label="removeLabel" @click="remove(it.key)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-else class="cap-empty">{{ emptyText }}</p>
|
||||
|
||||
<!-- The menu, on demand: a searchable wrap of toggle chips. -->
|
||||
<div v-if="open" class="cap-catalog">
|
||||
<input v-model="search" class="cap-search" :placeholder="searchPlaceholder" />
|
||||
<div class="cap-picks">
|
||||
<button
|
||||
v-for="it in filtered"
|
||||
:key="it.key"
|
||||
type="button"
|
||||
class="cap-pick"
|
||||
:class="{ on: isSelected(it.key) }"
|
||||
:title="it.desc || it.name"
|
||||
@click="toggle(it.key)"
|
||||
>{{ it.name }}</button>
|
||||
<p v-if="!filtered.length" class="cap-empty cap-empty--compact">{{ emptyText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface CapabilityItem {
|
||||
/** Stable identity used both as the v-model value and the list key. */
|
||||
key: string
|
||||
name: string
|
||||
desc?: string
|
||||
/** Tools only: reserved for source-specific affordances. */
|
||||
mcp?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
/** Selected keys (v-model). Strings to stay clear of Snowflake precision loss. */
|
||||
modelValue: string[]
|
||||
/** Full catalog to choose from. */
|
||||
items: CapabilityItem[]
|
||||
/** Drives the header glyph. */
|
||||
kind: 'skills' | 'tools'
|
||||
title: string
|
||||
/** Pre-formatted "N selected" text; empty hides the badge. */
|
||||
badge?: string
|
||||
emptyText: string
|
||||
addLabel: string
|
||||
collapseLabel: string
|
||||
searchPlaceholder: string
|
||||
removeLabel?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [string[]] }>()
|
||||
|
||||
const open = ref(false)
|
||||
const search = ref('')
|
||||
|
||||
const selectedKeys = computed(() => new Set(props.modelValue.map(String)))
|
||||
function isSelected(key: string): boolean {
|
||||
return selectedKeys.value.has(String(key))
|
||||
}
|
||||
|
||||
const selectedItems = computed(() => props.items.filter((it) => isSelected(it.key)))
|
||||
|
||||
const filtered = computed(() => {
|
||||
const q = search.value.trim().toLowerCase()
|
||||
if (!q) return props.items
|
||||
return props.items.filter((it) => `${it.name} ${it.desc ?? ''}`.toLowerCase().includes(q))
|
||||
})
|
||||
|
||||
function remove(key: string) {
|
||||
emit('update:modelValue', props.modelValue.filter((k) => String(k) !== String(key)))
|
||||
}
|
||||
|
||||
function toggle(key: string) {
|
||||
if (isSelected(key)) remove(key)
|
||||
else emit('update:modelValue', [...props.modelValue, key])
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cap-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px;
|
||||
padding: 16px 18px; box-shadow: var(--mc-shadow-soft); margin-bottom: 14px; }
|
||||
|
||||
.cap-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.cap-head-icon { width: 17px; height: 17px; color: var(--mc-primary); flex-shrink: 0; }
|
||||
.cap-title { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
.cap-badge { font-size: 11px; font-weight: 600; color: var(--mc-primary-hover); background: var(--mc-primary-bg);
|
||||
padding: 2px 8px; border-radius: 999px; }
|
||||
.cap-add { margin-left: auto; padding: 4px 10px; background: transparent; border: 1px solid var(--mc-border);
|
||||
border-radius: 999px; font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); cursor: pointer;
|
||||
font-family: inherit; }
|
||||
.cap-add:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
|
||||
/* Selected set — compact filled chips that wrap. */
|
||||
.cap-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.cap-chip { display: inline-flex; align-items: center; gap: 4px; padding: 4px 6px 4px 11px;
|
||||
background: var(--mc-primary-bg); border: 1px solid var(--mc-primary-light); border-radius: 999px;
|
||||
font-size: 13px; font-weight: 600; color: var(--mc-primary-hover); max-width: 100%; }
|
||||
.cap-chip-x { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px;
|
||||
padding: 0; border: none; background: transparent; color: var(--mc-primary-hover); cursor: pointer; opacity: 0.7; }
|
||||
.cap-chip-x:hover { opacity: 1; }
|
||||
.cap-chip-x svg { width: 12px; height: 12px; }
|
||||
|
||||
.cap-empty { font-size: 13px; color: var(--mc-text-tertiary); margin: 2px 0 0; }
|
||||
.cap-empty--compact { padding: 4px 2px; width: 100%; }
|
||||
|
||||
/* On-demand catalog — outline toggle chips, selected ones filled. */
|
||||
.cap-catalog { margin-top: 12px; }
|
||||
.cap-search { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid var(--mc-border);
|
||||
border-radius: 10px; background: var(--mc-input-bg); font-size: 14px; color: var(--mc-text-primary);
|
||||
font-family: inherit; outline: none; margin-bottom: 10px; }
|
||||
.cap-search:focus { border-color: var(--mc-primary); }
|
||||
.cap-picks { display: flex; flex-wrap: wrap; gap: 8px; max-height: 168px; overflow-y: auto; }
|
||||
.cap-pick { padding: 5px 12px; background: transparent; border: 1px solid var(--mc-border-light);
|
||||
border-radius: 999px; font-size: 13px; color: var(--mc-text-secondary); cursor: pointer; font-family: inherit;
|
||||
white-space: nowrap; }
|
||||
.cap-pick:hover { border-color: var(--mc-border-strong); color: var(--mc-text-primary); }
|
||||
.cap-pick.on { background: var(--mc-primary-bg); border-color: var(--mc-primary-light); color: var(--mc-primary-hover);
|
||||
font-weight: 600; }
|
||||
</style>
|
||||
@ -1352,6 +1352,56 @@ export default {
|
||||
contextHint: 'Manage context files (e.g. AGENT.md) that define this agent\'s behavior, knowledge, and instructions.',
|
||||
goToContext: 'Edit Context Files',
|
||||
},
|
||||
wizard: {
|
||||
entry: 'AI Create',
|
||||
kicker: 'AI Create',
|
||||
title: 'Create an employee in one sentence',
|
||||
subtitle: 'Describe the employee you need — AI configures the persona, type and tools; confirm to onboard.',
|
||||
steps: { describe: 'Describe', confirm: 'Confirm', onboard: 'Onboard' },
|
||||
placeholder: 'e.g. An ops assistant that can check the weather and write daily reports',
|
||||
inputHint: 'Enter to generate · Shift+Enter for newline',
|
||||
generate: 'Generate',
|
||||
generating: 'Generating…',
|
||||
tryLabel: 'Try:',
|
||||
examples: ['Quarterly business report', 'Contract risk review', 'Cross-team weekly digest'],
|
||||
generateFailed: 'Generation failed, please retry or rephrase',
|
||||
emptyRequirement: 'Please describe the employee you need first',
|
||||
aiNotice: 'The fields below were generated by AI from your description — edit anything before creating.',
|
||||
fields: {
|
||||
name: 'Name',
|
||||
icon: 'Icon',
|
||||
type: 'Type',
|
||||
description: 'Description',
|
||||
persona: 'Persona',
|
||||
tags: 'Tags',
|
||||
},
|
||||
sections: {
|
||||
tools: 'Tools & MCP',
|
||||
skills: 'Skills',
|
||||
kb: 'Knowledge base',
|
||||
},
|
||||
aiPicked: 'AI picked {count}',
|
||||
selected: '{count} selected',
|
||||
addSkill: 'Add skill',
|
||||
addTool: 'Add tool',
|
||||
collapse: 'Collapse',
|
||||
noAiSkills: 'No skills suggested — add manually',
|
||||
noAiTools: 'No tools suggested — add manually',
|
||||
remove: 'Remove',
|
||||
noTools: 'No tools available',
|
||||
noSkills: 'No skills available',
|
||||
kbNone: 'No knowledge base',
|
||||
back: 'Back to describe',
|
||||
confirmCreate: 'Confirm & onboard',
|
||||
creating: 'Creating…',
|
||||
createFailed: 'Creation failed',
|
||||
successTitle: '{name} is onboarded',
|
||||
successSubtitle: 'The new employee has joined your team and is ready to chat.',
|
||||
stat: { skills: 'Skills', tools: 'Tools', kb: 'KB' },
|
||||
startChat: 'Start chatting',
|
||||
buildAnother: 'Build another',
|
||||
roster: 'Roster',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
title: 'Security',
|
||||
|
||||
@ -1243,6 +1243,56 @@ export default {
|
||||
contextHint: '管理此智能体的上下文文件(如 AGENT.md),定义智能体的行为、知识和指令。',
|
||||
goToContext: '前往编辑上下文',
|
||||
},
|
||||
wizard: {
|
||||
entry: 'AI 创建',
|
||||
kicker: 'AI 创建',
|
||||
title: '一句话创建员工',
|
||||
subtitle: '描述你需要的员工,AI 自动配好人设、类型与工具,确认即可上岗',
|
||||
steps: { describe: '描述需求', confirm: '确认配置', onboard: '上岗' },
|
||||
placeholder: '例如:帮我做一个会查天气、能写日报的运营助手',
|
||||
inputHint: '回车生成 · Shift+回车换行',
|
||||
generate: '生成员工',
|
||||
generating: '正在生成…',
|
||||
tryLabel: '试试:',
|
||||
examples: ['季度经营分析报告', '合同条款风险审查', '跨部门周报汇总'],
|
||||
generateFailed: '生成失败,请重试或换一种描述',
|
||||
emptyRequirement: '请先描述你需要的员工',
|
||||
aiNotice: '以下内容由 AI 根据你的描述生成,可自由修改后再创建',
|
||||
fields: {
|
||||
name: '名称',
|
||||
icon: '图标',
|
||||
type: '类型',
|
||||
description: '描述',
|
||||
persona: '人设',
|
||||
tags: '标签',
|
||||
},
|
||||
sections: {
|
||||
tools: '工具 & MCP',
|
||||
skills: '技能 Skills',
|
||||
kb: '知识库 Wiki',
|
||||
},
|
||||
aiPicked: 'AI 已选 {count} 项',
|
||||
selected: '已选 {count}',
|
||||
addSkill: '添加技能',
|
||||
addTool: '添加工具',
|
||||
collapse: '收起',
|
||||
noAiSkills: 'AI 未推荐技能,可手动添加',
|
||||
noAiTools: 'AI 未推荐工具,可手动添加',
|
||||
remove: '移除',
|
||||
noTools: '暂无可用工具',
|
||||
noSkills: '暂无可用技能',
|
||||
kbNone: '不绑定知识库',
|
||||
back: '返回重新描述',
|
||||
confirmCreate: '确认创建并上岗',
|
||||
creating: '创建中…',
|
||||
createFailed: '创建失败',
|
||||
successTitle: '{name} 已上岗',
|
||||
successSubtitle: '新员工已加入你的团队,随时可以开始对话',
|
||||
stat: { skills: '技能', tools: '工具', kb: '知识库' },
|
||||
startChat: '立即对话',
|
||||
buildAnother: '再建一个',
|
||||
roster: '员工列表',
|
||||
},
|
||||
},
|
||||
security: {
|
||||
title: '安全管理',
|
||||
|
||||
@ -39,6 +39,12 @@ const router = createRouter({
|
||||
component: () => import('@/views/Agents.vue'),
|
||||
meta: { title: 'Agents', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
path: 'agents/create',
|
||||
name: 'AgentCreateWizard',
|
||||
component: () => import('@/views/AgentCreateWizard.vue'),
|
||||
meta: { title: 'Create Agent', requiredCapability: 'manage:agents' },
|
||||
},
|
||||
{
|
||||
// Live runtime view folded into the Agents page as a sub-view.
|
||||
// Kept as a redirect so old links / bookmarks still resolve.
|
||||
|
||||
525
mateclaw-ui/src/views/AgentCreateWizard.vue
Normal file
525
mateclaw-ui/src/views/AgentCreateWizard.vue
Normal file
@ -0,0 +1,525 @@
|
||||
<template>
|
||||
<div class="mc-page-shell">
|
||||
<div class="mc-page-frame">
|
||||
<div class="mc-page-inner wizard-page">
|
||||
|
||||
<!-- Step rail. Hidden on the first step so the input box is the sole
|
||||
focus; orientation only matters once the user has committed. -->
|
||||
<div v-if="step !== 'describe'" class="wiz-rail">
|
||||
<div class="wiz-step" :class="{ done: stepIndex > 0 }">
|
||||
<span class="wiz-dot">
|
||||
<svg v-if="stepIndex > 0" class="wiz-dot-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<template v-else>1</template>
|
||||
</span>
|
||||
<span class="wiz-step-label">{{ t('agents.wizard.steps.describe') }}</span>
|
||||
</div>
|
||||
<div class="wiz-line" :class="{ on: stepIndex > 0 }"></div>
|
||||
<div class="wiz-step" :class="{ done: stepIndex > 1, active: step === 'confirm' }">
|
||||
<span class="wiz-dot">
|
||||
<svg v-if="stepIndex > 1" class="wiz-dot-check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
<template v-else>2</template>
|
||||
</span>
|
||||
<span class="wiz-step-label">{{ t('agents.wizard.steps.confirm') }}</span>
|
||||
</div>
|
||||
<div class="wiz-line" :class="{ on: stepIndex > 1 }"></div>
|
||||
<div class="wiz-step" :class="{ active: step === 'success' }">
|
||||
<span class="wiz-dot">3</span>
|
||||
<span class="wiz-step-label">{{ t('agents.wizard.steps.onboard') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Short steps (describe / success) center vertically; the long
|
||||
confirm step flows from the top and scrolls. -->
|
||||
<div class="wiz-body" :class="{ 'wiz-body--center': step !== 'confirm' }">
|
||||
|
||||
<!-- Step 1: describe -->
|
||||
<template v-if="step === 'describe'">
|
||||
<div class="wiz-hero">
|
||||
<div class="wiz-hero-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="M12 3l1.8 5.4 5.4 1.8-5.4 1.8L12 17.4l-1.8-5.4L4.8 10.2l5.4-1.8z"/></svg>
|
||||
</div>
|
||||
<h1 class="wiz-title">{{ t('agents.wizard.title') }}</h1>
|
||||
<p class="wiz-subtitle">{{ t('agents.wizard.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wiz-input-card">
|
||||
<textarea
|
||||
v-model="requirement"
|
||||
class="wiz-textarea"
|
||||
rows="2"
|
||||
:placeholder="t('agents.wizard.placeholder')"
|
||||
@keydown.enter.exact.prevent="generate"
|
||||
></textarea>
|
||||
<div class="wiz-input-foot">
|
||||
<span class="wiz-hint">{{ t('agents.wizard.inputHint') }}</span>
|
||||
<button class="wiz-btn-primary" :disabled="generating || !requirement.trim()" @click="generate">
|
||||
<svg class="wiz-btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 3l1.8 5.4 5.4 1.8-5.4 1.8L12 17.4l-1.8-5.4L4.8 10.2l5.4-1.8z"/></svg>
|
||||
{{ generating ? t('agents.wizard.generating') : t('agents.wizard.generate') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-examples">
|
||||
<span class="wiz-try">{{ t('agents.wizard.tryLabel') }}</span>
|
||||
<button
|
||||
v-for="ex in exampleList"
|
||||
:key="ex"
|
||||
class="wiz-chip"
|
||||
:disabled="generating"
|
||||
@click="requirement = ex"
|
||||
>{{ ex }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Step 2: confirm config -->
|
||||
<template v-else-if="step === 'confirm' && draft">
|
||||
<div class="wiz-notice">
|
||||
<svg class="wiz-notice-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"><path d="M12 3l1.8 5.4 5.4 1.8-5.4 1.8L12 17.4l-1.8-5.4L4.8 10.2l5.4-1.8z"/></svg>
|
||||
{{ t('agents.wizard.aiNotice') }}
|
||||
</div>
|
||||
|
||||
<div class="wiz-card">
|
||||
<div class="wiz-grid">
|
||||
<div class="wiz-field">
|
||||
<label>{{ t('agents.wizard.fields.name') }} <span class="req">*</span></label>
|
||||
<input v-model="draft.name" class="wiz-control" />
|
||||
</div>
|
||||
<div class="wiz-field">
|
||||
<label>{{ t('agents.wizard.fields.icon') }}</label>
|
||||
<button type="button" class="wiz-icon-trigger" @click="iconPickerVisible = true">
|
||||
<SkillIcon :value="draft.icon" :size="22" fallback="🤖" />
|
||||
<span class="wiz-icon-trigger__label">{{ draft.icon || t('common.iconPicker.none') }}</span>
|
||||
<span class="wiz-icon-trigger__action">{{ t('common.iconPicker.pickerOpen') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="wiz-field">
|
||||
<label>{{ t('agents.wizard.fields.type') }}</label>
|
||||
<select v-model="draft.agentType" class="wiz-control">
|
||||
<option value="react">{{ t('agents.types.react') }}</option>
|
||||
<option value="plan_execute">{{ t('agents.types.planExecute') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="wiz-field wiz-full">
|
||||
<label>{{ t('agents.wizard.fields.description') }}</label>
|
||||
<input v-model="draft.description" class="wiz-control" />
|
||||
</div>
|
||||
<div class="wiz-field wiz-full">
|
||||
<label>{{ t('agents.wizard.fields.persona') }}</label>
|
||||
<textarea v-model="draft.systemPrompt" class="wiz-control" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="wiz-field wiz-full">
|
||||
<label>{{ t('agents.wizard.fields.tags') }}</label>
|
||||
<input v-model="tagsText" class="wiz-control" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Skills — chosen-first, full catalog on demand. -->
|
||||
<WizardCapabilityPicker
|
||||
kind="skills"
|
||||
v-model="selectedSkillIds"
|
||||
:items="skillItems"
|
||||
:title="t('agents.wizard.sections.skills')"
|
||||
:badge="selectedSkillIds.length ? t('agents.wizard.selected', { count: selectedSkillIds.length }) : ''"
|
||||
:empty-text="t('agents.wizard.noAiSkills')"
|
||||
:add-label="t('agents.wizard.addSkill')"
|
||||
:collapse-label="t('agents.wizard.collapse')"
|
||||
:search-placeholder="t('agents.binding.searchSkills')"
|
||||
:remove-label="t('agents.wizard.remove')"
|
||||
/>
|
||||
|
||||
<!-- Tools & MCP -->
|
||||
<WizardCapabilityPicker
|
||||
kind="tools"
|
||||
v-model="selectedToolNames"
|
||||
:items="toolItems"
|
||||
:title="t('agents.wizard.sections.tools')"
|
||||
:badge="selectedToolNames.length ? t('agents.wizard.selected', { count: selectedToolNames.length }) : ''"
|
||||
:empty-text="t('agents.wizard.noAiTools')"
|
||||
:add-label="t('agents.wizard.addTool')"
|
||||
:collapse-label="t('agents.wizard.collapse')"
|
||||
:search-placeholder="t('agents.binding.searchTools')"
|
||||
:remove-label="t('agents.wizard.remove')"
|
||||
/>
|
||||
|
||||
<!-- Knowledge base -->
|
||||
<div class="wiz-card">
|
||||
<div class="wiz-sec-head">
|
||||
<svg class="wiz-sec-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" 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"/></svg>
|
||||
<span class="wiz-sec-title">{{ t('agents.wizard.sections.kb') }}</span>
|
||||
</div>
|
||||
<select v-model="selectedKbId" class="wiz-control">
|
||||
<option :value="null">{{ t('agents.wizard.kbNone') }}</option>
|
||||
<option v-for="kb in bindableKBs" :key="kb.id" :value="String(kb.id)">{{ kb.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="wiz-actions">
|
||||
<button class="wiz-btn-ghost" :disabled="creating" @click="backToDescribe">{{ t('agents.wizard.back') }}</button>
|
||||
<button class="wiz-btn-primary" :disabled="creating || !draft.name.trim()" @click="confirmCreate">
|
||||
{{ creating ? t('agents.wizard.creating') : t('agents.wizard.confirmCreate') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Shared icon picker, same component the agent edit page uses. -->
|
||||
<SkillIconPicker
|
||||
v-model:visible="iconPickerVisible"
|
||||
:model-value="draft.icon"
|
||||
@apply="(v: string) => { if (draft) draft.icon = v }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Step 3: success -->
|
||||
<template v-else-if="step === 'success' && createdAgent">
|
||||
<div class="wiz-hero">
|
||||
<div class="wiz-hero-icon success">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
</div>
|
||||
<h1 class="wiz-title">{{ t('agents.wizard.successTitle', { name: createdAgent.name }) }}</h1>
|
||||
<p class="wiz-subtitle">{{ t('agents.wizard.successSubtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="wiz-card">
|
||||
<div class="wiz-success-head">
|
||||
<SkillIcon :value="createdAgent.icon" :size="44" fallback="🤖" />
|
||||
<div>
|
||||
<p class="wiz-success-name">{{ createdAgent.name }}</p>
|
||||
<p class="wiz-success-desc">{{ createdAgent.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wiz-stats">
|
||||
<div class="wiz-stat"><div class="wiz-stat-num">{{ selectedSkillIds.length }}</div><div class="wiz-stat-label">{{ t('agents.wizard.stat.skills') }}</div></div>
|
||||
<div class="wiz-stat"><div class="wiz-stat-num">{{ selectedToolNames.length }}</div><div class="wiz-stat-label">{{ t('agents.wizard.stat.tools') }}</div></div>
|
||||
<div class="wiz-stat"><div class="wiz-stat-num">{{ selectedKbId ? 1 : 0 }}</div><div class="wiz-stat-label">{{ t('agents.wizard.stat.kb') }}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wiz-actions wiz-actions--success">
|
||||
<button class="wiz-btn-primary wiz-grow" @click="goChat">{{ t('agents.wizard.startChat') }}</button>
|
||||
<button class="wiz-btn-ghost" @click="buildAnother">{{ t('agents.wizard.buildAnother') }}</button>
|
||||
<button class="wiz-btn-ghost" @click="goRoster">{{ t('agents.wizard.roster') }}</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { agentApi, agentBindingApi, toolApi, skillApi, wikiApi } from '@/api'
|
||||
import { mcToast } from '@/composables/useMcToast'
|
||||
import SkillIcon from '@/components/common/SkillIcon.vue'
|
||||
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
|
||||
import WizardCapabilityPicker from '@/components/agent/WizardCapabilityPicker.vue'
|
||||
|
||||
const { t, tm, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
|
||||
type Step = 'describe' | 'confirm' | 'success'
|
||||
const step = ref<Step>('describe')
|
||||
const stepIndex = computed(() => (step.value === 'describe' ? 0 : step.value === 'confirm' ? 1 : 2))
|
||||
|
||||
const requirement = ref('')
|
||||
const generating = ref(false)
|
||||
const creating = ref(false)
|
||||
const iconPickerVisible = ref(false)
|
||||
|
||||
interface Draft {
|
||||
name: string
|
||||
icon: string
|
||||
description: string
|
||||
agentType: string
|
||||
systemPrompt: string
|
||||
role?: string
|
||||
goal?: string
|
||||
tags: string[]
|
||||
recommendedQuestions: string[]
|
||||
tools: string[]
|
||||
skillIds: string[]
|
||||
primaryKbId: string | number | null
|
||||
}
|
||||
const draft = ref<Draft | null>(null)
|
||||
const createdAgent = ref<any>(null)
|
||||
|
||||
// Editable capability selections (IDs kept as strings per Snowflake contract).
|
||||
const tagsText = ref('')
|
||||
const availableTools = ref<any[]>([])
|
||||
const selectedToolNames = ref<string[]>([])
|
||||
const availableSkills = ref<any[]>([])
|
||||
const selectedSkillIds = ref<string[]>([])
|
||||
const bindableKBs = ref<any[]>([])
|
||||
const selectedKbId = ref<string | null>(null)
|
||||
|
||||
const exampleList = computed<string[]>(() => {
|
||||
const ex = tm('agents.wizard.examples') as unknown
|
||||
return Array.isArray(ex) ? (ex as string[]) : []
|
||||
})
|
||||
|
||||
function skillName(s: any): string {
|
||||
if (locale.value === 'zh-CN' && s.nameZh) return s.nameZh
|
||||
if (locale.value !== 'zh-CN' && s.nameEn) return s.nameEn
|
||||
return s.name
|
||||
}
|
||||
|
||||
// Catalogs shaped for WizardCapabilityPicker — keys are strings throughout.
|
||||
const skillItems = computed(() =>
|
||||
availableSkills.value.map((s: any) => ({ key: String(s.id), name: skillName(s), desc: s.description })))
|
||||
const toolItems = computed(() =>
|
||||
availableTools.value.map((t: any) => ({ key: t.name, name: t.name, desc: t.description, mcp: t.source === 'mcp' })))
|
||||
|
||||
async function loadCatalogs() {
|
||||
const [toolsRes, skillsRes, kbRes] = await Promise.allSettled([
|
||||
toolApi.listAvailable(),
|
||||
skillApi.page({ enabled: true, size: 200 }),
|
||||
wikiApi.listBindableKBs(),
|
||||
])
|
||||
if (toolsRes.status === 'fulfilled') {
|
||||
const all = ((toolsRes.value as any).data || []) as any[]
|
||||
availableTools.value = all.filter((tl) => tl.available && !tl.stale)
|
||||
}
|
||||
if (skillsRes.status === 'fulfilled') {
|
||||
const data = (skillsRes.value as any).data
|
||||
availableSkills.value = (data?.records || data || []) as any[]
|
||||
}
|
||||
if (kbRes.status === 'fulfilled') {
|
||||
bindableKBs.value = ((kbRes.value as any).data || []) as any[]
|
||||
}
|
||||
}
|
||||
|
||||
async function generate() {
|
||||
if (!requirement.value.trim()) {
|
||||
mcToast.error(t('agents.wizard.emptyRequirement'))
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
const [res] = await Promise.all([
|
||||
agentApi.generate(requirement.value.trim()) as any,
|
||||
loadCatalogs(),
|
||||
])
|
||||
const d = res.data as Draft
|
||||
draft.value = {
|
||||
name: d.name || '',
|
||||
icon: d.icon || '🤖',
|
||||
description: d.description || '',
|
||||
agentType: d.agentType || 'react',
|
||||
systemPrompt: d.systemPrompt || '',
|
||||
role: d.role,
|
||||
goal: d.goal,
|
||||
tags: Array.isArray(d.tags) ? d.tags : [],
|
||||
recommendedQuestions: Array.isArray(d.recommendedQuestions) ? d.recommendedQuestions : [],
|
||||
tools: Array.isArray(d.tools) ? d.tools : [],
|
||||
skillIds: Array.isArray(d.skillIds) ? d.skillIds.map(String) : [],
|
||||
primaryKbId: d.primaryKbId != null ? String(d.primaryKbId) : null,
|
||||
}
|
||||
tagsText.value = draft.value.tags.join(', ')
|
||||
selectedToolNames.value = [...draft.value.tools]
|
||||
selectedSkillIds.value = [...draft.value.skillIds]
|
||||
selectedKbId.value = draft.value.primaryKbId != null ? String(draft.value.primaryKbId) : null
|
||||
step.value = 'confirm'
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('agents.wizard.generateFailed'))
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmCreate() {
|
||||
if (!draft.value || !draft.value.name.trim()) return
|
||||
creating.value = true
|
||||
try {
|
||||
const tags = tagsText.value
|
||||
.split(/[,,]/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
const payload: any = {
|
||||
name: draft.value.name.trim(),
|
||||
icon: draft.value.icon,
|
||||
description: draft.value.description,
|
||||
agentType: draft.value.agentType,
|
||||
systemPrompt: draft.value.systemPrompt,
|
||||
tags,
|
||||
enabled: true,
|
||||
maxIterations: 10,
|
||||
// primaryKbId kept as string to preserve Snowflake precision.
|
||||
primaryKbId: selectedKbId.value,
|
||||
}
|
||||
const res: any = await agentApi.create(payload)
|
||||
const agentId = res.data?.id
|
||||
if (!agentId) throw new Error(t('agents.wizard.createFailed'))
|
||||
|
||||
// Apply capability bindings sequentially so a partial failure is
|
||||
// attributable. IDs flow through as strings (Snowflake contract).
|
||||
await agentBindingApi.setSkills(agentId, selectedSkillIds.value as any)
|
||||
await agentBindingApi.setTools(agentId, selectedToolNames.value)
|
||||
await agentBindingApi.setKbs(agentId, selectedKbId.value ? [selectedKbId.value] : [])
|
||||
|
||||
createdAgent.value = res.data
|
||||
step.value = 'success'
|
||||
} catch (e: any) {
|
||||
mcToast.error(e?.message || t('agents.wizard.createFailed'))
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToDescribe() {
|
||||
step.value = 'describe'
|
||||
}
|
||||
|
||||
function buildAnother() {
|
||||
requirement.value = ''
|
||||
draft.value = null
|
||||
createdAgent.value = null
|
||||
tagsText.value = ''
|
||||
selectedToolNames.value = []
|
||||
selectedSkillIds.value = []
|
||||
selectedKbId.value = null
|
||||
step.value = 'describe'
|
||||
}
|
||||
|
||||
function goChat() {
|
||||
if (createdAgent.value) {
|
||||
router.push({ path: '/chat', query: { agentId: String(createdAgent.value.id) } })
|
||||
}
|
||||
}
|
||||
|
||||
function goRoster() {
|
||||
router.push({ path: '/agents' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wizard-page { max-width: 720px; margin: 0 auto; padding: 24px 20px 48px; box-sizing: border-box;
|
||||
min-height: calc(100vh - 132px); display: flex; flex-direction: column; }
|
||||
|
||||
/* Short steps fill the remaining height and center; the tall confirm step
|
||||
keeps natural top-aligned flow so it scrolls instead of clipping. */
|
||||
.wiz-body { flex: 1 1 auto; min-width: 0; }
|
||||
.wiz-body--center { display: flex; flex-direction: column; justify-content: center; }
|
||||
|
||||
/* Step rail. Explicit width (not max-width) because the page is a flex
|
||||
column — margin:auto there suppresses the stretch, collapsing the rail to
|
||||
content width and zeroing the flex:1 connector lines. */
|
||||
.wiz-rail { display: flex; align-items: center; justify-content: center; width: min(460px, 100%); margin: 0 auto 28px; }
|
||||
.wiz-step { display: flex; align-items: center; gap: 8px; }
|
||||
.wiz-dot { width: 26px; height: 26px; border-radius: 50%; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 13px; font-weight: 700; background: var(--mc-bg-muted); border: 1px solid var(--mc-border); color: var(--mc-text-tertiary); }
|
||||
.wiz-step.active .wiz-dot { background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: #fff; border-color: transparent; }
|
||||
.wiz-step.done .wiz-dot { background: var(--mc-accent-soft); color: var(--mc-accent); border-color: transparent; }
|
||||
.wiz-dot-check { width: 13px; height: 13px; }
|
||||
.wiz-step-label { font-size: 13px; color: var(--mc-text-tertiary); }
|
||||
.wiz-step.active .wiz-step-label { color: var(--mc-text-primary); font-weight: 600; }
|
||||
.wiz-line { flex: 1; height: 2px; background: var(--mc-border-light); margin: 0 12px; }
|
||||
.wiz-line.on { background: var(--mc-primary); }
|
||||
|
||||
/* Hero */
|
||||
.wiz-hero { text-align: center; margin-bottom: 22px; }
|
||||
.wiz-hero-icon { width: 56px; height: 56px; border-radius: 50%; background: var(--mc-primary-bg); display: inline-flex;
|
||||
align-items: center; justify-content: center; color: var(--mc-primary); margin-bottom: 12px; }
|
||||
.wiz-hero-icon svg { width: 26px; height: 26px; }
|
||||
.wiz-hero-icon.success { background: var(--mc-accent-soft); color: var(--mc-accent); }
|
||||
.wiz-hero-icon.success svg { width: 30px; height: 30px; }
|
||||
.wiz-title { font-size: 28px; font-weight: 800; letter-spacing: -0.03em; color: var(--mc-text-primary); margin: 0 0 6px; }
|
||||
.wiz-subtitle { font-size: 15px; color: var(--mc-text-secondary); line-height: 1.7; margin: 0; }
|
||||
|
||||
/* Input card */
|
||||
.wiz-input-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px;
|
||||
padding: 14px 16px; box-shadow: var(--mc-shadow-soft); }
|
||||
.wiz-textarea { width: 100%; border: none; background: transparent; resize: none; font-size: 16px; line-height: 1.6;
|
||||
color: var(--mc-text-primary); font-family: inherit; outline: none; }
|
||||
.wiz-input-foot { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
||||
.wiz-hint { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
|
||||
/* Examples */
|
||||
.wiz-examples { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
|
||||
.wiz-try { font-size: 12px; color: var(--mc-text-tertiary); }
|
||||
.wiz-chip { font-size: 13px; color: var(--mc-text-secondary); background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light);
|
||||
border-radius: 999px; padding: 5px 13px; cursor: pointer; }
|
||||
.wiz-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
|
||||
/* Notice */
|
||||
.wiz-notice { display: flex; align-items: center; gap: 8px; padding: 10px 14px; margin-bottom: 16px;
|
||||
background: var(--mc-primary-bg); border: 1px solid var(--mc-primary-light); border-radius: 12px;
|
||||
font-size: 13px; font-weight: 600; color: var(--mc-primary-hover); }
|
||||
|
||||
/* Cards */
|
||||
.wiz-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px;
|
||||
padding: 18px 20px; box-shadow: var(--mc-shadow-soft); margin-bottom: 14px; }
|
||||
.wiz-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 16px; }
|
||||
.wiz-field { display: flex; flex-direction: column; }
|
||||
.wiz-field.wiz-full { grid-column: 1 / -1; }
|
||||
.wiz-field > label { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin-bottom: 6px; }
|
||||
.wiz-field .req { color: var(--mc-primary); }
|
||||
.wiz-control { width: 100%; box-sizing: border-box; padding: 9px 12px; border: 1px solid var(--mc-border);
|
||||
border-radius: 10px; background: var(--mc-input-bg); font-size: 14px; color: var(--mc-text-primary);
|
||||
font-family: inherit; outline: none; }
|
||||
.wiz-control:focus { border-color: var(--mc-primary); }
|
||||
textarea.wiz-control { resize: vertical; line-height: 1.6; }
|
||||
.wiz-icon-trigger { display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
|
||||
padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 10px; background: var(--mc-input-bg);
|
||||
cursor: pointer; font-family: inherit; text-align: left; }
|
||||
.wiz-icon-trigger:hover { border-color: var(--mc-border-strong); }
|
||||
.wiz-icon-trigger__label { font-size: 14px; color: var(--mc-text-primary); }
|
||||
.wiz-icon-trigger__action { margin-left: auto; font-size: 12px; color: var(--mc-primary); font-weight: 600; }
|
||||
|
||||
/* Section header */
|
||||
.wiz-sec-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
|
||||
.wiz-sec-icon { width: 17px; height: 17px; color: var(--mc-primary); flex-shrink: 0; }
|
||||
.wiz-notice-icon { width: 16px; height: 16px; flex-shrink: 0; }
|
||||
.wiz-sec-title { font-size: 14px; font-weight: 700; color: var(--mc-text-primary); }
|
||||
|
||||
/* Actions */
|
||||
.wiz-actions { display: flex; justify-content: space-between; align-items: center; margin-top: 18px; }
|
||||
.wiz-actions--success { gap: 10px; }
|
||||
.wiz-grow { flex: 1; }
|
||||
.wiz-btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 10px 18px;
|
||||
background: linear-gradient(135deg, var(--mc-primary), var(--mc-primary-hover)); color: #fff; border: none;
|
||||
border-radius: 14px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: var(--mc-shadow-soft); }
|
||||
.wiz-btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
.wiz-btn-icon { width: 16px; height: 16px; }
|
||||
.wiz-btn-ghost { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 10px 16px;
|
||||
background: transparent; color: var(--mc-text-secondary); border: 1px solid var(--mc-border); border-radius: 14px;
|
||||
font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.wiz-btn-ghost:hover { border-color: var(--mc-border-strong); }
|
||||
|
||||
/* Success */
|
||||
.wiz-success-head { display: flex; align-items: center; gap: 14px; margin-bottom: 16px; }
|
||||
.wiz-success-name { font-size: 17px; font-weight: 700; color: var(--mc-text-primary); margin: 0; }
|
||||
.wiz-success-desc { font-size: 13px; color: var(--mc-text-secondary); margin: 4px 0 0; }
|
||||
.wiz-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; border-top: 1px solid var(--mc-border-light); padding-top: 14px; }
|
||||
.wiz-stat { text-align: center; }
|
||||
.wiz-stat-num { font-size: 22px; font-weight: 800; color: var(--mc-primary-hover); }
|
||||
.wiz-stat-label { font-size: 12px; color: var(--mc-text-tertiary); margin-top: 2px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.wizard-page { min-height: calc(100vh - 96px); padding: 16px 14px 32px; }
|
||||
.wiz-grid { grid-template-columns: 1fr; }
|
||||
.wiz-rail { margin-bottom: 20px; }
|
||||
.wiz-line { margin: 0 6px; }
|
||||
.wiz-step { gap: 5px; }
|
||||
.wiz-step-label { font-size: 12px; }
|
||||
.wiz-title { font-size: 22px; }
|
||||
.wiz-subtitle { font-size: 14px; }
|
||||
.wiz-hero-icon { width: 48px; height: 48px; }
|
||||
.wiz-card { padding: 14px 14px; }
|
||||
.wiz-input-foot { flex-direction: column; align-items: stretch; gap: 10px; }
|
||||
.wiz-input-foot .wiz-btn-primary { width: 100%; }
|
||||
.wiz-actions { flex-wrap: wrap; gap: 10px; }
|
||||
.wiz-actions--success { flex-direction: column; align-items: stretch; }
|
||||
.wiz-actions--success .wiz-btn-primary,
|
||||
.wiz-actions--success .wiz-btn-ghost { width: 100%; }
|
||||
}
|
||||
|
||||
/* Below a phone's narrowest common width, drop the inactive rail labels so
|
||||
the three-step indicator never wraps awkwardly. */
|
||||
@media (max-width: 420px) {
|
||||
.wiz-step:not(.active) .wiz-step-label { display: none; }
|
||||
}
|
||||
</style>
|
||||
@ -31,6 +31,12 @@
|
||||
>{{ liveRunning }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-secondary" style="display:inline-flex;align-items:center;gap:6px;" @click="router.push('/agents/create')">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 3l1.9 4.6L18.5 9.5l-4.6 1.9L12 16l-1.9-4.6L5.5 9.5l4.6-1.9z"/>
|
||||
</svg>
|
||||
{{ t('agents.wizard.entry') }}
|
||||
</button>
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user