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:
matevip 2026-06-17 17:38:42 +08:00
parent 6e7c137154
commit 1522009aec
10 changed files with 1219 additions and 0 deletions

View File

@ -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 资源" 的跨域攻击

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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),

View 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>

View File

@ -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',

View File

@ -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: '安全管理',

View File

@ -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.

View 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>

View File

@ -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"/>