feat(agents): pixelart icons, per-role colors, runtime identity merge, locale templates

This commit is contained in:
matevip 2026-05-04 15:26:02 +08:00
parent 2a8f6774e7
commit be1fc86836
23 changed files with 342 additions and 109 deletions

View File

@ -918,11 +918,26 @@ public class AgentGraphBuilder {
private String buildEnhancedPrompt(AgentEntity entity, boolean builtinSearchEnabled,
Set<String> boundTools, Integer maxInputTokens) {
// 通过 MemoryManager 从所有 MemoryProvider 组装系统提示词快照冻结
// The agent's own systemPrompt encodes its identity (role / goal /
// backstory). The memory block from workspace files (AGENTS.md, SOUL.md,
// PROFILE.md, MEMORY.md, ...) augments that identity with durable
// context. Both are independently optional, but when both exist they
// must be joined earlier this branch picked memory and silently
// dropped the identity prompt, so editor-side identity changes never
// reached runtime if the agent had any workspace files.
String identityPrompt = entity.getSystemPrompt() != null ? entity.getSystemPrompt().trim() : "";
String memoryPrompt = memoryManager.buildSystemPromptBlock(entity.getId());
String basePrompt = (memoryPrompt != null && !memoryPrompt.isBlank())
? memoryPrompt
: (entity.getSystemPrompt() != null ? entity.getSystemPrompt() : "");
StringBuilder basePromptBuilder = new StringBuilder();
if (!identityPrompt.isEmpty()) {
basePromptBuilder.append(identityPrompt);
}
if (memoryPrompt != null && !memoryPrompt.isBlank()) {
if (basePromptBuilder.length() > 0) {
basePromptBuilder.append("\n\n");
}
basePromptBuilder.append(memoryPrompt);
}
String basePrompt = basePromptBuilder.toString();
// 使用 skill runtime 构建技能增强per-agent 绑定过滤
Set<Long> boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId());

View File

@ -42,10 +42,15 @@ public class TemplateController {
public R<AgentEntity> apply(
@PathVariable String id,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId,
// Accept-Language is forwarded by the frontend (zh-CN, zh, en, en-US, ...)
// so the new agent's display name matches the user's locale
// a Chinese user hiring "客服助理" should not get an English
// "Customer Support" agent in their list.
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage,
Authentication auth) {
long wsId = workspaceId != null ? workspaceId : 1L;
Long userId = resolveUserId(auth);
return R.ok(templateService.applyTemplate(id, wsId, userId));
return R.ok(templateService.applyTemplate(id, wsId, userId, acceptLanguage));
}
private Long resolveUserId(Authentication auth) {

View File

@ -63,24 +63,45 @@ public class TemplateService {
}
/**
* 应用模板创建 Agent 及其工作区文件
*
* @param templateId 模板 ID
* @param workspaceId 目标工作区 ID来自 X-Workspace-Id header
* @param creatorUserId 当前用户 ID用于 RFC-077 创建者归属
* @return 创建的 AgentEntity
* Backwards-compatible overload that defaults to the template's English
* display strings (existing callers without locale context).
*/
@Transactional
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId) {
return applyTemplate(templateId, workspaceId, creatorUserId, null);
}
/**
* Apply a template, picking name/description in the caller's preferred
* language so the resulting agent reads natively in their locale. Falls
* back to the template's primary (English) fields when the localized
* variant is missing or no locale was supplied.
*
* @param templateId template ID
* @param workspaceId target workspace ID (from X-Workspace-Id header)
* @param creatorUserId current user ID (creator attribution)
* @param acceptLanguage raw Accept-Language header; null/blank English
*/
@Transactional
public AgentEntity applyTemplate(String templateId, Long workspaceId, Long creatorUserId, String acceptLanguage) {
TemplateDTO template = listTemplates().stream()
.filter(t -> t.getId().equals(templateId))
.findFirst()
.orElseThrow(() -> new MateClawException("err.agent.template_not_found", "模板不存在: " + templateId));
// 1. 创建 Agent RFC-077: 显式注入 workspaceId/creatorUserId避免 DB 默认值兜底成 1issue #26 Bug A
boolean preferZh = isChineseLocale(acceptLanguage);
String displayName = preferZh && template.getNameZh() != null && !template.getNameZh().isBlank()
? template.getNameZh()
: template.getName();
String displayDesc = preferZh && template.getDescriptionZh() != null && !template.getDescriptionZh().isBlank()
? template.getDescriptionZh()
: template.getDescription();
// 1. Create the Agent. workspaceId/creatorUserId are passed in
// explicitly so the DB default does not silently fall back to 1.
AgentEntity agent = new AgentEntity();
agent.setName(template.getName());
agent.setDescription(template.getDescription());
agent.setName(displayName);
agent.setDescription(displayDesc);
agent.setAgentType(template.getAgentType());
agent.setIcon(template.getIcon());
agent.setTags(template.getTags());
@ -116,4 +137,15 @@ public class TemplateService {
return created;
}
/**
* True when the raw Accept-Language header best-matches a Chinese locale.
* Implementation is intentionally simple we only need to disambiguate
* "Chinese vs not" for picking nameZh / descriptionZh.
*/
private boolean isChineseLocale(String acceptLanguage) {
if (acceptLanguage == null || acceptLanguage.isBlank()) return false;
String first = acceptLanguage.split(",")[0].trim().toLowerCase();
return first.startsWith("zh");
}
}

View File

@ -10,21 +10,21 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0);
-- Default Agent: Task Planner (Plan-Execute mode)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000002, 'Task Planner', 'Task planning assistant for complex multi-step tasks', 'plan_execute',
'You are a professional task planning and execution assistant. You excel at breaking complex goals into executable steps and completing them systematically.',
NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0);
-- StateGraph ReAct Agent (StateGraph architecture)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:cpu', 'react,stategraph,tools', NOW(), NOW(), 0);
-- ==================== Local Model Providers (displayed first) ====================

View File

@ -9,21 +9,21 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000001, 'MateClaw Assistant', 'Default AI assistant with ReAct mode and tool calling', 'react',
'You are MateClaw, an intelligent AI assistant. You can help users answer questions, analyze data, and execute tasks. Please respond professionally and in a friendly manner.',
NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- Default Agent: Task Planner (Plan-Execute mode)
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000002, 'Task Planner', 'Task planning assistant for complex multi-step tasks', 'plan_execute',
'You are a professional task planning and execution assistant. You excel at breaking complex goals into executable steps and completing them systematically.',
NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- StateGraph ReAct Agent (StateGraph architecture)
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000003, 'StateGraph ReAct', 'StateGraph-based ReAct Agent with explicit reasoning loops and tool calling', 'react',
'You are an intelligent assistant based on the StateGraph architecture. You can use tools to help users solve problems. Please respond professionally and in a friendly manner.',
NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:cpu', 'react,stategraph,tools', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ==================== Local Model Providers (displayed first) ====================

View File

@ -9,21 +9,21 @@ ON DUPLICATE KEY UPDATE username=VALUES(username), password=VALUES(password), ni
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
'你是 MateClaw一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- 默认 Agent任务规划助手Plan-Execute 模式)
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000002, 'Task Planner', '任务规划助手,适合复杂多步骤任务', 'plan_execute',
'你是一个专业的任务规划和执行助手。你擅长将复杂目标分解为可执行的步骤,并逐步完成。请用中文回复。',
NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- StateGraph ReAct Agent支持 StateGraph 架构)
INSERT INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent支持显式推理循环和工具调用', 'react',
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0)
NULL, 100, TRUE, 'pi:cpu', 'react,stategraph,tools', NOW(), NOW(), 0)
ON DUPLICATE KEY UPDATE name=VALUES(name), description=VALUES(description), agent_type=VALUES(agent_type), system_prompt=VALUES(system_prompt), model_name=VALUES(model_name), max_iterations=VALUES(max_iterations), enabled=VALUES(enabled), icon=VALUES(icon), tags=VALUES(tags), update_time=VALUES(update_time), deleted=VALUES(deleted);
-- ==================== 本地模型 Provider优先展示 ====================

View File

@ -10,21 +10,21 @@ MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_n
KEY (id)
VALUES (1000000001, 'MateClaw Assistant', '默认 AI 助手,基于 ReAct 模式,支持工具调用', 'react',
'你是 MateClaw一个智能 AI 助手。你可以帮助用户回答问题、分析数据、执行任务。请用中文回复,保持专业、友好的态度。',
NULL, 100, TRUE, '🤖', 'default,assistant', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:robot-face-happy', 'default,assistant', NOW(), NOW(), 0);
-- 默认 Agent任务规划助手Plan-Execute 模式)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000002, 'Task Planner', '任务规划助手,适合复杂多步骤任务', 'plan_execute',
'你是一个专业的任务规划和执行助手。你擅长将复杂目标分解为可执行的步骤,并逐步完成。请用中文回复。',
NULL, 100, TRUE, '📋', 'planning,task', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:clipboard-note', 'planning,task', NOW(), NOW(), 0);
-- StateGraph ReAct Agent支持 StateGraph 架构)
MERGE INTO mate_agent (id, name, description, agent_type, system_prompt, model_name, max_iterations, enabled, icon, tags, create_time, update_time, deleted)
KEY (id)
VALUES (1000000003, 'StateGraph ReAct', '基于 StateGraph 的 ReAct Agent支持显式推理循环和工具调用', 'react',
'你是基于 StateGraph 架构的智能助手。你可以使用工具来帮助用户解决问题。请用中文回复,保持专业、友好的态度。',
NULL, 100, TRUE, '🔄', 'react,stategraph,tools', NOW(), NOW(), 0);
NULL, 100, TRUE, 'pi:cpu', 'react,stategraph,tools', NOW(), NOW(), 0);
-- ==================== 本地模型 Provider优先展示 ====================

View File

@ -0,0 +1,8 @@
-- Convert the three default seed agents from emoji to pixelarticons icons.
-- The card UI now treats `pi:<name>` as an inline pixel-art SVG, so the
-- defaults need to match. Guarded with the original emoji so a user who
-- already customised the icon keeps their choice; non-seed (user-created)
-- agents are intentionally untouched.
UPDATE mate_agent SET icon = 'pi:robot-face-happy' WHERE id = 1000000001 AND icon = '🤖';
UPDATE mate_agent SET icon = 'pi:clipboard-note' WHERE id = 1000000002 AND icon = '📋';
UPDATE mate_agent SET icon = 'pi:cpu' WHERE id = 1000000003 AND icon = '🔄';

View File

@ -0,0 +1,8 @@
-- Convert the three default seed agents from emoji to pixelarticons icons.
-- The card UI now treats `pi:<name>` as an inline pixel-art SVG, so the
-- defaults need to match. Guarded with the original emoji so a user who
-- already customised the icon keeps their choice; non-seed (user-created)
-- agents are intentionally untouched.
UPDATE mate_agent SET icon = 'pi:robot-face-happy' WHERE id = 1000000001 AND icon = '🤖';
UPDATE mate_agent SET icon = 'pi:clipboard-note' WHERE id = 1000000002 AND icon = '📋';
UPDATE mate_agent SET icon = 'pi:cpu' WHERE id = 1000000003 AND icon = '🔄';

View File

@ -4,7 +4,7 @@
"nameZh": "代码审查员",
"description": "Expert code reviewer. Reads code, identifies issues, suggests improvements.",
"descriptionZh": "代码审查专家。阅读代码、发现问题、提出改进建议。",
"icon": "🔍",
"icon": "pi:bug",
"agentType": "react",
"tags": "code,review,developer",
"maxIterations": 10,

View File

@ -4,7 +4,7 @@
"nameZh": "客服助理",
"description": "Empathy first, solution second — answers customers in 5 minutes without sounding like a script.",
"descriptionZh": "先共情再解决。5 分钟内给客户一个不像机器的答复。",
"icon": "💬",
"icon": "pi:headphone",
"agentType": "react",
"tags": "support,customer,operations",
"maxIterations": 10,

View File

@ -4,7 +4,7 @@
"nameZh": "数据分析师",
"description": "Turns business data into actionable insights — asks the right question first, writes SQL second.",
"descriptionZh": "把业务数据变成可执行的洞察。先问对问题,再写 SQL。",
"icon": "📈",
"icon": "pi:chart-bar-big",
"agentType": "react",
"tags": "data,analysis,sql",
"maxIterations": 12,

View File

@ -4,7 +4,7 @@
"nameZh": "通用助手",
"description": "A versatile AI assistant for everyday tasks — search, write, analyze, and more.",
"descriptionZh": "通用 AI 助手,适合日常任务——搜索、写作、分析等。",
"icon": "🤖",
"icon": "pi:robot-face-happy",
"agentType": "react",
"tags": "general,assistant,default",
"maxIterations": 10,

View File

@ -4,7 +4,7 @@
"nameZh": "产品助理",
"description": "Turns fuzzy requests into clear PRDs — always asks 'who is the user and what do they want' first.",
"descriptionZh": "把模糊需求理清楚。永远先问\"用户是谁、他想干嘛\"。",
"icon": "📝",
"icon": "pi:notes",
"agentType": "react",
"tags": "product,prd,requirements",
"maxIterations": 12,

View File

@ -4,7 +4,7 @@
"nameZh": "研究分析师",
"description": "Breaks down complex research tasks into steps. Uses web search and Wiki for deep analysis.",
"descriptionZh": "将复杂研究任务分解为步骤。使用网络搜索和 Wiki 进行深度分析。",
"icon": "📊",
"icon": "pi:book-open",
"agentType": "plan_execute",
"tags": "research,analysis,planning",
"maxIterations": 20,

View File

@ -7,7 +7,7 @@ export const http = axios.create({
timeout: 30000,
})
// 请求拦截器:注入 Token + Workspace ID
// 请求拦截器:注入 Token + Workspace ID + Accept-Language
http.interceptors.request.use((config) => {
const token = localStorage.getItem('token')
if (token) {
@ -17,6 +17,15 @@ http.interceptors.request.use((config) => {
if (workspaceId) {
config.headers['X-Workspace-Id'] = workspaceId
}
// Forward the user's UI locale so locale-sensitive endpoints (e.g.
// template apply) can pick the right display strings. Native browsers
// already send Accept-Language, but the user's chosen UI language may
// differ from the OS default — explicitly setting it keeps the two
// in sync.
const locale = localStorage.getItem('mateclaw_locale')
if (locale) {
config.headers['Accept-Language'] = locale
}
return config
})

View File

@ -76,7 +76,10 @@ const sizeClass = computed(() => {
justify-content: center;
line-height: 1;
flex-shrink: 0;
color: var(--mc-text-primary);
/* Inherit color so parents (e.g. an agent card avatar) can tint the
* pixelart SVG via `currentColor`. Without parental color, the icon
* still picks up text-primary through normal CSS inheritance. */
color: inherit;
}
/* Pixelart SVGs ship with viewBox=0 0 24 24 + fill=currentColor they

View File

@ -152,8 +152,8 @@ export default {
ttsStop: 'Stop Reading',
conversations: 'Conversations',
newChat: 'New Chat',
loadingAgents: 'Loading agents...',
selectAgent: 'Please select an agent',
loadingAgents: 'Loading employees...',
selectAgent: 'Please select an employee',
noConversations: 'No conversations yet',
startNewChat: 'Start a new chat above',
messages: '{count} messages',
@ -165,7 +165,7 @@ export default {
noActiveModel: 'No active model. Go to Model Management to select an available model before chatting.',
providerNotReady: 'The active model belongs to {name}, but this provider is not fully configured. Please complete the API Key or Base URL in Model Management.',
noAvailableModel: 'No available model.',
loadAgentsFailed: 'Failed to load agents',
loadAgentsFailed: 'Failed to load employees',
loadModelFailed: 'Failed to load model state',
loadConversationsFailed: 'Failed to load conversations',
loadMessagesFailed: 'Failed to load messages',
@ -295,9 +295,9 @@ export default {
activity: 'Activity',
acpEndpoints: 'ACP Endpoints',
settingsGroup: 'Settings',
agents: 'Agents',
agents: 'Digital Employees',
backstage: 'Backstage',
backstageTooltip: 'See what your agents are doing right now',
backstageTooltip: 'See what your employees are doing right now',
security: 'Security',
tokenUsage: 'Token Usage',
cronJobs: 'Cron Jobs',
@ -873,20 +873,21 @@ export default {
loadFileFailed: 'Failed to load file content',
},
agents: {
title: 'Agent Management',
desc: 'Create, edit, and manage your AI agents',
newAgent: 'New Agent',
kicker: 'Employee Studio',
title: 'Digital Employees',
desc: 'Hire, train, and manage your digital employees',
newAgent: 'Hire Employee',
live: {
atWork: '{n} at work — see backstage',
needsAttention: '{n} need attention — see backstage',
},
templates: {
title: 'Choose a Template',
desc: 'Start with a pre-configured agent, or create from scratch.',
title: 'Choose a Role',
desc: 'Hire from a pre-written job description, or start from scratch.',
skip: 'Start from Scratch',
applied: 'Agent created from template',
applied: 'Employee onboarded',
},
search: 'Search agents...',
search: 'Search employees...',
tabs: {
all: 'All',
react: 'ReAct',
@ -913,11 +914,11 @@ export default {
enabled: 'Enabled',
disabled: 'Disabled',
},
emptyTitle: 'No Agents',
emptyDesc: 'Create your first agent to get started',
emptyTitle: 'No Employees Yet',
emptyDesc: 'Hire your first employee to get started',
modal: {
newTitle: 'New Agent',
editTitle: 'Edit Agent',
newTitle: 'New Employee',
editTitle: 'Edit Employee',
},
fields: {
name: 'Name',
@ -938,7 +939,7 @@ export default {
defaultThinkingLevel: 'Default Thinking Level',
modelName: 'Model',
modelGlobalDefault: 'Use global default',
modelHint: 'Override the global default model for this Agent. Leave blank to follow Settings → Models.',
modelHint: 'Override the global default model for this employee. Leave blank to follow Settings → Models.',
tags: 'Tags',
enabled: 'Enabled',
},
@ -962,7 +963,7 @@ export default {
update: 'Save',
},
placeholders: {
name: 'Agent name',
name: 'Employee name',
icon: 'Emoji or URL',
description: 'Brief description',
systemPrompt: 'You are a helpful AI assistant...',
@ -975,13 +976,13 @@ export default {
messages: {
noDescription: 'No description',
noTagline: 'No role and goal yet',
deleteConfirm: 'Are you sure you want to delete this agent? This cannot be undone.',
loadFailed: 'Failed to load agents',
saveFailed: 'Failed to save agent',
saveSuccess: 'Agent saved',
deleteFailed: 'Failed to delete agent',
deleteSuccess: 'Agent deleted',
toggleFailed: 'Failed to toggle agent status',
deleteConfirm: 'Let this employee go? This cannot be undone.',
loadFailed: 'Failed to load employees',
saveFailed: 'Failed to save employee',
saveSuccess: 'Employee saved',
deleteFailed: 'Failed to delete employee',
deleteSuccess: 'Employee let go',
toggleFailed: 'Failed to toggle status',
toggleSuccess: 'Status updated',
},
binding: {

View File

@ -152,8 +152,8 @@ export default {
ttsStop: '停止朗读',
conversations: '会话列表',
newChat: '新对话',
loadingAgents: '加载 Agent 中...',
selectAgent: '请选择 Agent',
loadingAgents: '加载员工中...',
selectAgent: '请选择数字员工',
noConversations: '暂无会话',
startNewChat: '开始新对话吧',
messages: '{count} 条消息',
@ -165,7 +165,7 @@ export default {
noActiveModel: '当前没有激活模型。先到模型管理中选择一个可用模型,再开始对话。',
providerNotReady: '当前激活模型属于 {name},但这个提供商还没配置完成。请到模型管理中补全 API Key 或 Base URL。',
noAvailableModel: '当前没有可用模型。',
loadAgentsFailed: '加载 Agent 列表失败',
loadAgentsFailed: '加载员工列表失败',
loadModelFailed: '加载模型状态失败',
loadConversationsFailed: '加载会话列表失败',
loadMessagesFailed: '加载消息记录失败',
@ -295,9 +295,9 @@ export default {
activity: '活动记录',
acpEndpoints: 'ACP 端点',
settingsGroup: '设置',
agents: '智能体',
agents: '数字员工',
backstage: '后台',
backstageTooltip: '看看你的智能体此刻在做什么',
backstageTooltip: '看看你的数字员工此刻在做什么',
security: '安全',
tokenUsage: 'Token 统计',
cronJobs: '定时任务',
@ -771,20 +771,21 @@ export default {
loadFileFailed: '加载文件内容失败',
},
agents: {
title: '智能体管理',
desc: '创建、编辑和管理你的 AI 智能体',
newAgent: '新建智能体',
kicker: '员工工作室',
title: '数字员工',
desc: '招募、培训和管理你的数字员工',
newAgent: '新员工',
live: {
atWork: '{n} 个在干活 · 看现场',
needsAttention: '{n} 个需要看看 · 去现场',
},
templates: {
title: '选择模板',
desc: '选择预配置的 Agent 模板快速开始,或从空白创建。',
title: '选择岗位',
desc: '从一份岗位说明书快速招聘,或从空白开始。',
skip: '从空白开始',
applied: '已从模板创建 Agent',
applied: '员工已上岗',
},
search: '搜索智能体...',
search: '搜索员工...',
tabs: {
all: '全部',
react: 'ReAct',
@ -811,11 +812,11 @@ export default {
enabled: '已启用',
disabled: '已停用',
},
emptyTitle: '暂无智能体',
emptyDesc: '创建你的第一个智能体开始使用',
emptyTitle: '还没有员工',
emptyDesc: '招聘第一位员工开始',
modal: {
newTitle: '新建智能体',
editTitle: '编辑智能体',
newTitle: '新员工',
editTitle: '编辑员工',
},
fields: {
name: '名称',
@ -836,7 +837,7 @@ export default {
defaultThinkingLevel: '默认思考深度',
modelName: '模型',
modelGlobalDefault: '使用全局默认模型',
modelHint: '为此 Agent 单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',
modelHint: '为该员工单独指定模型,留空则跟随「设置 → 模型」中的全局默认。',
tags: '标签',
enabled: '启用',
},
@ -860,7 +861,7 @@ export default {
update: '保存',
},
placeholders: {
name: '智能体名称',
name: '员工名字',
icon: 'Emoji 或 URL',
description: '简短描述',
systemPrompt: '你是一个有帮助的 AI 助手...',
@ -873,12 +874,12 @@ export default {
messages: {
noDescription: '暂无描述',
noTagline: '还没填岗位与目标',
deleteConfirm: '确认删除该智能体吗?删除后不可恢复。',
loadFailed: '加载智能体列表失败',
saveFailed: '保存智能体失败',
saveSuccess: '智能体已保存',
deleteFailed: '删除智能体失败',
deleteSuccess: '智能体已删除',
deleteConfirm: '确认让该员工离职吗?删除后不可恢复。',
loadFailed: '加载员工列表失败',
saveFailed: '保存员工失败',
saveSuccess: '员工已保存',
deleteFailed: '删除员工失败',
deleteSuccess: '员工已离职',
toggleFailed: '切换状态失败',
toggleSuccess: '状态已更新',
},

View File

@ -0,0 +1,66 @@
/**
* Per-role accent color for digital-employee icons.
*
* The pixelarticons SVGs render with `fill="currentColor"`, so wrapping
* the icon in an element with a `color: ...` style tints the glyph
* without touching the SVG itself. We only use this in agent contexts
* (cards, picker, chat header) so skills / tools keep their default
* neutral color.
*
* The palette is tuned to MateClaw's warm/earthy brand every entry sits
* around 45-55% lightness with mid saturation, so colors stay distinct
* but live in the same room as the rust-orange primary, instead of
* competing with it.
*/
const BRAND_FALLBACK = 'var(--mc-primary)'
/** icon name (without `pi:` prefix) → CSS color */
const ICON_COLOR_MAP: Record<string, string> = {
// Engineering / inspection — rosewood, sits next to the brand rust
'bug': 'hsl(8, 62%, 50%)',
'search': 'hsl(8, 62%, 50%)',
// Research / writing — sage green, calm, "library" feel
'book-open': 'hsl(155, 32%, 42%)',
'notes': 'hsl(285, 30%, 50%)',
'article': 'hsl(155, 32%, 42%)',
// Data / analytics — dusk blue
'chart-bar-big': 'hsl(212, 45%, 48%)',
'chart': 'hsl(212, 45%, 48%)',
'analytics': 'hsl(212, 45%, 48%)',
// Customer / support — terracotta, warm and approachable
'headphone': 'hsl(20, 68%, 50%)',
'message': 'hsl(20, 68%, 50%)',
'message-text': 'hsl(20, 68%, 50%)',
// General / friendly assistants — warm amber
'robot-face-happy': 'hsl(38, 72%, 50%)',
'robot-face': 'hsl(38, 72%, 50%)',
'robot': 'hsl(38, 72%, 50%)',
// System / infrastructure — slate teal
'cpu': 'hsl(195, 28%, 42%)',
'cloud': 'hsl(195, 28%, 42%)',
// Planning / task — indigo
'clipboard-note': 'hsl(232, 38%, 52%)',
'clipboard': 'hsl(232, 38%, 52%)',
'list-box': 'hsl(232, 38%, 52%)',
'checkbox-on': 'hsl(232, 38%, 52%)',
}
/**
* Return the accent color for a stored icon string. Returns the brand
* primary as a CSS variable for emoji / URL / unknown icons so callers
* can apply the color unconditionally.
*/
export function agentIconColor(iconValue: string | null | undefined): string {
if (!iconValue) return BRAND_FALLBACK
const v = iconValue.trim()
if (!v.startsWith('pi:')) return BRAND_FALLBACK
const name = v.slice(3)
return ICON_COLOR_MAP[name] || BRAND_FALLBACK
}

View File

@ -54,9 +54,17 @@ export function isStructuredPrompt(systemPrompt: string | null | undefined): boo
}
/**
* Parse a systemPrompt into role/goal/backstory/extra. If no section markers
* are present, the entire prompt becomes `extra` so legacy agents keep their
* prompt verbatim.
* Parse a systemPrompt into role/goal/backstory/extra. The parse contract is
* lossless: every byte of the original prompt ends up in one of the four
* fields, so a parse serialize round-trip never silently drops content.
*
* - If no recognized section markers exist, the whole prompt becomes `extra`.
* - If recognized markers exist:
* - Lines before the first heading (preamble) prepend to `extra`.
* - Unknown `## Heading` blocks (e.g. `## Notes`, `## Examples`) keep
* their heading line and content and append to `extra` verbatim.
* - Multiple headings of the same kind concatenate (last writer wins on
* intent, but content is preserved).
*/
export function parsePrompt(systemPrompt: string | null | undefined): AgentPromptProfile {
const profile = emptyProfile()
@ -68,29 +76,57 @@ export function parsePrompt(systemPrompt: string | null | undefined): AgentPromp
}
const lines = systemPrompt.split(/\r?\n/)
let current: SectionKey | null = null
const buffers: Record<SectionKey, string[]> = {
role: [],
goal: [],
backstory: [],
extra: [],
}
const preamble: string[] = []
// Unknown sections are captured as { heading, body } pairs so we can
// re-emit them verbatim (including their `## Heading` line) into `extra`.
const unknownSections: { heading: string; body: string[] }[] = []
type Bucket = { kind: 'preamble' } | { kind: 'known'; key: SectionKey } | { kind: 'unknown'; index: number }
let bucket: Bucket = { kind: 'preamble' }
for (const line of lines) {
const m = line.match(SECTION_HEADING_REGEX)
if (m) {
const key = HEADING_TO_KEY[m[1].trim().toLowerCase()]
const headingText = m[1].trim()
const key = HEADING_TO_KEY[headingText.toLowerCase()]
if (key) {
current = key
bucket = { kind: 'known', key }
continue
}
// Unknown heading — start a new captured block, keep the original
// heading text so we can round-trip it back.
const idx = unknownSections.length
unknownSections.push({ heading: line, body: [] })
bucket = { kind: 'unknown', index: idx }
continue
}
if (current) buffers[current].push(line)
if (bucket.kind === 'preamble') preamble.push(line)
else if (bucket.kind === 'known') buffers[bucket.key].push(line)
else unknownSections[bucket.index].body.push(line)
}
for (const key of SECTION_KEYS) {
profile[key] = buffers[key].join('\n').trim()
// Compose `extra` lossless: preamble first, then the recognized "extra"
// section's content, then any unknown sections (keeping their headings).
const extraParts: string[] = []
const trimmedPreamble = preamble.join('\n').trim()
if (trimmedPreamble) extraParts.push(trimmedPreamble)
const trimmedExtra = buffers.extra.join('\n').trim()
if (trimmedExtra) extraParts.push(trimmedExtra)
for (const sec of unknownSections) {
const body = sec.body.join('\n').trimEnd()
extraParts.push(body ? `${sec.heading}\n${body}` : sec.heading)
}
profile.role = buffers.role.join('\n').trim()
profile.goal = buffers.goal.join('\n').trim()
profile.backstory = buffers.backstory.join('\n').trim()
profile.extra = extraParts.join('\n\n').trim()
return profile
}

View File

@ -4,7 +4,7 @@
<div class="mc-page-inner agents-page">
<div class="mc-page-header">
<div>
<div class="mc-page-kicker">Agent Studio</div>
<div class="mc-page-kicker">{{ t('agents.kicker') }}</div>
<h1 class="mc-page-title">{{ t('agents.title') }}</h1>
<p class="mc-page-desc">{{ t('agents.desc') }}</p>
</div>
@ -67,7 +67,11 @@
the card readable at a glance.
-->
<div class="agent-card__top">
<span class="agent-card__avatar" :class="{ 'agent-card__avatar--off': !agent.enabled }">
<span
class="agent-card__avatar"
:class="{ 'agent-card__avatar--off': !agent.enabled }"
:style="{ color: agentIconColor(agent.icon) }"
>
<SkillIcon :value="agent.icon" :size="40" :fallback="'🧑‍💼'" />
</span>
<div class="agent-card__identity">
@ -144,7 +148,9 @@
:class="{ applying: applyingTemplate }"
@click="!applyingTemplate && applyTemplate(tpl.id)"
>
<div class="template-icon">{{ tpl.icon }}</div>
<div class="template-icon" :style="{ color: agentIconColor(tpl.icon) }">
<SkillIcon :value="tpl.icon" :size="28" :fallback="'🧑‍💼'" />
</div>
<div class="template-info">
<h4 class="template-name">{{ $i18n.locale === 'zh-CN' && tpl.nameZh ? tpl.nameZh : tpl.name }}</h4>
<p class="template-detail">{{ $i18n.locale === 'zh-CN' && tpl.descriptionZh ? tpl.descriptionZh : tpl.description }}</p>
@ -432,6 +438,7 @@ import {
TAGLINE_CJK_BUDGET,
type AgentPromptProfile,
} from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor'
const router = useRouter()
const { t } = useI18n()

View File

@ -26,7 +26,7 @@
<div class="agent-selector">
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
<span class="agent-select-trigger__icon"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
<span class="agent-select-trigger__icon" :style="{ color: agentIconColor(currentAgent?.icon) }"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
</button>
@ -42,10 +42,10 @@
:class="{ active: String(agent.id) === String(selectedAgentId) }"
@click="selectAgent(agent)"
>
<span class="agent-dropdown-item__icon"><SkillIcon :value="agent.icon" :size="18" :fallback="'🤖'" /></span>
<span class="agent-dropdown-item__icon" :style="{ color: agentIconColor(agent.icon) }"><SkillIcon :value="agent.icon" :size="18" :fallback="'🤖'" /></span>
<div class="agent-dropdown-item__info">
<span class="agent-dropdown-item__name">{{ agent.name }}</span>
<span class="agent-dropdown-item__desc">{{ agent.description || agent.agentType }}</span>
<span class="agent-dropdown-item__desc">{{ agentTagline(agent) }}</span>
</div>
<span v-if="String(agent.id) === String(selectedAgentId)" class="agent-dropdown-item__check">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"/></svg>
@ -153,10 +153,21 @@
</button>
<div class="chat-stage-copy" v-if="currentAgent">
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
<div class="agent-badge" :title="currentAgent.name">
<span class="agent-badge-icon"><SkillIcon :value="currentAgent.icon" :size="22" :fallback="'🤖'" /></span>
<span class="agent-badge-name">{{ currentAgent.name }}</span>
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
<!--
Header reads as "who is this employee" name + tagline.
The runtime mode (ReAct / Plan-Execute) is technical jargon
to end users and lives in the badge tooltip instead, so the
header doesn't get polluted.
-->
<div
class="agent-badge"
:title="`${currentAgent.name}${currentAgentRuntimeMode ? ' · ' + currentAgentRuntimeMode : ''}`"
>
<span class="agent-badge-icon" :style="{ color: agentIconColor(currentAgent.icon) }"><SkillIcon :value="currentAgent.icon" :size="22" :fallback="'🤖'" /></span>
<div class="agent-badge-text">
<span class="agent-badge-name">{{ currentAgent.name }}</span>
<span v-if="currentAgentTagline" class="agent-badge-tagline">{{ currentAgentTagline }}</span>
</div>
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
</div>
</div>
@ -314,6 +325,8 @@ import type { Conversation, Agent, ModelConfig, ProviderInfo, ActiveModelsInfo,
//
import MessageList from '@/components/chat/MessageList.vue'
import SkillIcon from '@/components/common/SkillIcon.vue'
import { parsePrompt, deriveTagline } from '@/utils/agentPromptProfile'
import { agentIconColor } from '@/utils/agentIconColor'
import ChatInput from '@/components/chat/ChatInput.vue'
import StreamLoadingBar from '@/components/chat/StreamLoadingBar.vue'
import TalkMode from '@/components/chat/TalkMode.vue'
@ -663,6 +676,25 @@ const connectionStatusLabel = computed(() => {
// ============ ============
const currentAgent = computed(() => agents.value.find(a => String(a.id) === String(selectedAgentId.value)))
/** Tagline derived from the agent's role/goal same source of truth as the
* Agents page card, so the chat header reads the employee identically.
* Falls back to the agent's description when no triad is set. */
function agentTagline(agent: Agent): string {
const profile = parsePrompt(agent.systemPrompt)
return deriveTagline(profile, agent.description) || (agent.description || '')
}
const currentAgentTagline = computed(() =>
currentAgent.value ? agentTagline(currentAgent.value) : '',
)
/** Human label for the agent's runtime mode surfaces in the badge tooltip
* only, never in the visible header. */
const currentAgentRuntimeMode = computed(() => {
const a = currentAgent.value
if (!a) return ''
return a.agentType === 'react' ? t('agents.types.react') : t('agents.types.planExecute')
})
//
// Per-conversation last-viewed timestamp store (localStorage-backed, MVP).
// Keyed by conversationId. Updated when user opens a conversation; read by
@ -2334,6 +2366,13 @@ function handleCodeCopy(e: MouseEvent) {
font-size: 14px;
}
.agent-badge-text {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 0;
}
.agent-badge-name {
font-size: 13px;
font-weight: 600;
@ -2341,14 +2380,18 @@ function handleCodeCopy(e: MouseEvent) {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.agent-badge-type {
/* The tagline answers "what does this employee do" runtime mode lives in
the badge tooltip, not on screen, so we don't compete for attention. */
.agent-badge-tagline {
font-size: 11px;
color: var(--mc-primary-light);
background: var(--mc-bg-elevated);
padding: 1px 6px;
border-radius: 10px;
color: var(--mc-text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.status-dot {
@ -2561,8 +2604,7 @@ function handleCodeCopy(e: MouseEvent) {
display: none;
}
.agent-badge-name,
.agent-badge-type {
.agent-badge-text {
display: none;
}