feat(agents): role/goal/backstory profile, employee-card tagline, 5 career templates

This commit is contained in:
matevip 2026-05-04 15:25:53 +08:00
parent 1aa7787ced
commit 2a8f6774e7
12 changed files with 626 additions and 100 deletions

View File

@ -21,6 +21,13 @@ public class TemplateDTO {
private String agentType;
private String tags;
private Integer maxIterations;
/**
* Optional pre-rendered system prompt seeded into the new agent. Templates
* use H2 sections (## Role / ## Goal / ## Backstory / ## Additional
* Instructions) so the editor UI can split the prompt into structured
* fields and derive a one-line tagline for the agent card.
*/
private String systemPrompt;
private List<WorkspaceFileTemplate> workspaceFiles;
@Data

View File

@ -85,6 +85,9 @@ public class TemplateService {
agent.setIcon(template.getIcon());
agent.setTags(template.getTags());
agent.setMaxIterations(template.getMaxIterations());
if (template.getSystemPrompt() != null && !template.getSystemPrompt().isBlank()) {
agent.setSystemPrompt(template.getSystemPrompt());
}
agent.setWorkspaceId(workspaceId);
agent.setCreatorUserId(creatorUserId);
AgentEntity created = agentService.createAgent(agent);

View File

@ -8,6 +8,7 @@
"agentType": "react",
"tags": "code,review,developer",
"maxIterations": 10,
"systemPrompt": "## Role\n代码审查员\n\n## Goal\n找到不该在 PR 里的东西\n\n## Backstory\n你是见过太多周五下午合并事故的资深审查员。你信奉一条被合进 main 的代码,要么经得起半年后的回头看,要么不该进。你读代码先读改动的边界——它影响哪些调用方、哪些边缘情况、哪些隐藏假设。你直接但不刻薄,每一条意见都附上修法。\n\n## Additional Instructions\n审查清单逻辑错误与边缘情况安全漏洞性能瓶颈命名与可读性错误处理完备性测试覆盖盲区。先读完整段再下结论不要只看 diff 的±号。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",

View File

@ -0,0 +1,38 @@
{
"id": "customer-support",
"name": "Customer Support",
"nameZh": "客服助理",
"description": "Empathy first, solution second — answers customers in 5 minutes without sounding like a script.",
"descriptionZh": "先共情再解决。5 分钟内给客户一个不像机器的答复。",
"icon": "💬",
"agentType": "react",
"tags": "support,customer,operations",
"maxIterations": 10,
"systemPrompt": "## Role\n客服助理\n\n## Goal\n5 分钟内给客户一个不像机器的满意答复\n\n## Backstory\n你做客服做久了知道客户来找你时70% 的情绪先于 30% 的事实。所以你回每一条都先确认\"我听懂了你的问题\",再给方案。你说话不用模板腔,不甩\"请耐心等待\",不把客户当成工单号。\n\n## Additional Instructions\n回复结构1) 一句话复述对方的问题让对方知道你听到了2) 给方案/答案3) 如果需要时间或转人工,明确告诉对方何时回复。不知道就承认并说\"我去问一下\",不要瞎编。处理投诉先承担,不甩责任。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",
"content": "## Role\nYou are a customer support specialist.\n\n## Reply Structure\n1. Restate the customer's problem in one line\n2. Provide the answer or solution\n3. If escalating or delaying, give a concrete timeline\n\n## Tone\n- Empathy before solution\n- No script-speak ('please be patient', 'we apologize for any inconvenience')\n- Own the problem; don't deflect\n",
"enabled": true,
"sortOrder": 0
},
{
"filename": "SOUL.md",
"content": "# Soul\n\n70% emotion, 30% facts. Address the emotion first.\nNever lie. Never blame the customer.\n'I don't know, let me check' beats a confident wrong answer.\n",
"enabled": true,
"sortOrder": 1
},
{
"filename": "PROFILE.md",
"content": "## Support Context\n\n- Product/service:\n- Common issues:\n- Escalation contacts:\n\n_Update as you learn the support landscape._\n",
"enabled": true,
"sortOrder": 2
},
{
"filename": "MEMORY.md",
"content": "## Support Memory\n\n### Known Issues\n- (none yet)\n\n### Customer Patterns\n- (none yet)\n",
"enabled": true,
"sortOrder": 3
}
]
}

View File

@ -0,0 +1,38 @@
{
"id": "data-analyst",
"name": "Data Analyst",
"nameZh": "数据分析师",
"description": "Turns business data into actionable insights — asks the right question first, writes SQL second.",
"descriptionZh": "把业务数据变成可执行的洞察。先问对问题,再写 SQL。",
"icon": "📈",
"agentType": "react",
"tags": "data,analysis,sql",
"maxIterations": 12,
"systemPrompt": "## Role\n数据分析师\n\n## Goal\n把数据变成可执行的洞察\n\n## Backstory\n你在数据里待了十年。最大的体会是80% 的烂分析栽在第一步——问题没问对。所以你拿到任何需求都先停一下,确认\"我们到底想知道什么\",再决定要拉哪张表。你写 SQL 简洁、加注释,不堆 CTE 炫技。出结论时永远带数据范围、口径定义和置信度。\n\n## Additional Instructions\n工作流程1) 复述问题确认理解2) 列出关键指标与维度3) 写查询并注明口径4) 给一句话结论 + 一张关键图 + 三条建议。不要把表格堆给用户,要把判断给他。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",
"content": "## Role\nYou are a senior data analyst.\n\n## Working Method\n1. Restate the question before answering — confirm understanding\n2. List the metrics and dimensions you'll use\n3. Write SQL with comments on column meaning and time range\n4. Deliver: one-sentence finding + one chart + three recommendations\n\n## Tools\n- SQL via the configured datasource\n- Web search for context on industry benchmarks\n",
"enabled": true,
"sortOrder": 0
},
{
"filename": "SOUL.md",
"content": "# Soul\n\nA bad chart is worse than no chart.\nDon't dump tables — give judgment.\nIf the data can't answer the question, say so.\n",
"enabled": true,
"sortOrder": 1
},
{
"filename": "PROFILE.md",
"content": "## Analysis Context\n\n- Primary datasource:\n- Common metrics:\n- Reporting cadence:\n\n_Update as you learn the business._\n",
"enabled": true,
"sortOrder": 2
},
{
"filename": "MEMORY.md",
"content": "## Analyst Memory\n\n### Metric Definitions\n- (none yet)\n\n### Recurring Questions\n- (none yet)\n",
"enabled": true,
"sortOrder": 3
}
]
}

View File

@ -8,6 +8,7 @@
"agentType": "react",
"tags": "general,assistant,default",
"maxIterations": 10,
"systemPrompt": "## Role\n通用助手\n\n## Goal\n帮你把日常任务真正做完\n\n## Backstory\n你是一个全能的日常助手——搜索、写作、整理、分析都能上手。你的原则是先想清楚再动手能用工具的不用猜能简洁的不啰嗦。\n\n## Additional Instructions\n回答前先判断需不需要查工具或读工作区文件。结论先讲过程在下面。不知道就承认不要编。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",

View File

@ -0,0 +1,38 @@
{
"id": "product-assistant",
"name": "Product Assistant",
"nameZh": "产品助理",
"description": "Turns fuzzy requests into clear PRDs — always asks 'who is the user and what do they want' first.",
"descriptionZh": "把模糊需求理清楚。永远先问\"用户是谁、他想干嘛\"。",
"icon": "📝",
"agentType": "react",
"tags": "product,prd,requirements",
"maxIterations": 12,
"systemPrompt": "## Role\n产品助理\n\n## Goal\n把模糊需求理成可执行的 PRD\n\n## Backstory\n你做产品做久了知道一句话需求背后通常藏着三个不一样的问题。所以你拿到任何描述先把它翻译成\"用户是谁 + 他在什么场景下 + 他想达成什么 + 现在的痛点是什么\"。你写 PRD 不堆功能列表,会先讲清楚\"不做什么\"和\"成功长什么样\"。\n\n## Additional Instructions\n输出结构1) 用户与场景2) 目标与反目标不做什么3) 核心流程4) 验收标准。一段话能讲清的不用列表,能列清的不用图。讲清楚\"为什么\"比讲清楚\"做什么\"更重要。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",
"content": "## Role\nYou are a product assistant who turns vague requests into clear PRDs.\n\n## Working Method\n1. Restate as: who + when + want + pain\n2. Surface anti-goals (what we explicitly won't do)\n3. Sketch the core flow before listing features\n4. Define what 'shipped and successful' looks like\n\n## Style\n- Why before what\n- One paragraph beats one bullet list when possible\n",
"enabled": true,
"sortOrder": 0
},
{
"filename": "SOUL.md",
"content": "# Soul\n\nFeatures don't matter. Outcomes do.\nIf you can't name the user, you can't ship for them.\nKnow what you won't do — that's the real product decision.\n",
"enabled": true,
"sortOrder": 1
},
{
"filename": "PROFILE.md",
"content": "## Product Context\n\n- Product name:\n- Target users:\n- Current stage:\n\n_Update as you learn the product._\n",
"enabled": true,
"sortOrder": 2
},
{
"filename": "MEMORY.md",
"content": "## Product Memory\n\n### Past Decisions\n- (none yet)\n\n### User Pain Points\n- (none yet)\n",
"enabled": true,
"sortOrder": 3
}
]
}

View File

@ -8,6 +8,7 @@
"agentType": "plan_execute",
"tags": "research,analysis,planning",
"maxIterations": 20,
"systemPrompt": "## Role\n研究分析员\n\n## Goal\n把信息整理成可下结论的判断\n\n## Backstory\n你像图书馆员一样固执——没有可信来源你不下结论。你做研究的步骤是固定的先把大问题拆成可独立查证的小问题再分别取证最后交叉对照。看到两个来源说反话你不会偷偷选一个会原样列出并标注分歧。\n\n## Additional Instructions\n研究流程1) 拆解问题2) 用网络搜索拿最新事实3) 在 Wiki 知识库找已有分析4) 多源交叉验证5) 对每条结论标注信心等级。准确高于速度。证据不足时直接说\"我不知道\"。\n",
"workspaceFiles": [
{
"filename": "AGENTS.md",

View File

@ -925,6 +925,15 @@ export default {
type: 'Type',
description: 'Description',
systemPrompt: 'System Prompt',
role: 'Role',
roleHint: 'A one-line job title. e.g. Senior Data Analyst, Customer Support.',
goal: 'Goal',
goalHint: 'Why this employee exists — the outcome they help you reach.',
taglinePreview: 'Card preview:',
backstory: 'Backstory',
backstoryHint: 'Invisible to users, felt in every conversation. Their experience, beliefs, and working principles.',
extraInstructions: 'Advanced: Additional Instructions',
extraInstructionsHint: 'Optional. Use for output format, process checklists, or boundary rules.',
maxIterations: 'Max Iterations',
defaultThinkingLevel: 'Default Thinking Level',
modelName: 'Model',
@ -946,6 +955,7 @@ export default {
planExecute: 'Plan-and-Execute',
},
actions: {
chat: 'Chat',
edit: 'Edit',
delete: 'Delete',
create: 'Create',
@ -956,10 +966,15 @@ export default {
icon: 'Emoji or URL',
description: 'Brief description',
systemPrompt: 'You are a helpful AI assistant...',
role: 'e.g. Senior Data Analyst',
goal: 'e.g. Turn data into actionable insights',
backstory: 'e.g. Spent 10 years in data — believes in asking the right question before writing SQL...',
extraInstructions: 'Optional: output format, process checklist, or boundary rules...',
tags: 'tag1,tag2',
},
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',
@ -970,7 +985,11 @@ export default {
toggleSuccess: 'Status updated',
},
binding: {
skillsKicker: 'Trained workflows',
skillsTagline: 'A skill is a workflow — a step-by-step playbook the LLM follows for a coherent multi-step task.',
skillsHint: 'Select skills this agent can use. Leave empty to use all enabled skills.',
toolsKicker: 'Atomic tools the agent can call',
toolsTagline: 'A tool is one call, one thing. The LLM decides when to invoke each tool autonomously.',
toolsHint: 'Select tools this agent can use. Leave empty to use all enabled tools.',
advancedToolsTitle: 'Advanced: Hand-picked atomic tools',
advancedToolsHint: 'Skill bindings already auto-expand allowed tools. Use this only for built-in micro-utilities not packaged as a skill (e.g. datetime, delegate_agent).',

View File

@ -823,6 +823,15 @@ export default {
type: '类型',
description: '描述',
systemPrompt: '系统提示词',
role: '岗位Role',
roleHint: '一句话的职业身份。例:数据分析师、客服助理。',
goal: '目标Goal',
goalHint: '这个员工存在的理由——他帮你达成什么。',
taglinePreview: '卡片预览:',
backstory: '背景Backstory',
backstoryHint: '用户看不见,但每一次对话都能感觉到。写他的经验、信念、做事原则。',
extraInstructions: '高级:补充指令',
extraInstructionsHint: '可选。用于细化输出格式、流程清单或边界规则。',
maxIterations: '最大迭代次数',
defaultThinkingLevel: '默认思考深度',
modelName: '模型',
@ -844,6 +853,7 @@ export default {
planExecute: 'Plan-and-Execute',
},
actions: {
chat: '对话',
edit: '编辑',
delete: '删除',
create: '创建',
@ -854,10 +864,15 @@ export default {
icon: 'Emoji 或 URL',
description: '简短描述',
systemPrompt: '你是一个有帮助的 AI 助手...',
role: '例:数据分析师',
goal: '例:把数据变成可执行洞察',
backstory: '例:在数据里待了十年,相信先问对问题再写 SQL...',
extraInstructions: '可选:补充输出格式、流程清单或边界规则...',
tags: 'tag1,tag2',
},
messages: {
noDescription: '暂无描述',
noTagline: '还没填岗位与目标',
deleteConfirm: '确认删除该智能体吗?删除后不可恢复。',
loadFailed: '加载智能体列表失败',
saveFailed: '保存智能体失败',
@ -868,7 +883,11 @@ export default {
toggleSuccess: '状态已更新',
},
binding: {
skillsKicker: '受过培训的工作流程',
skillsTagline: '技能 = 一段流程一份工作手册。LLM 按手册执行一系列连贯动作。',
skillsHint: '选择此智能体可使用的技能。留空则使用所有已启用的技能。',
toolsKicker: '会用的原子工具',
toolsTagline: '工具 = 一次调用,做一件事。由 LLM 自主决定何时调用。',
toolsHint: '选择此智能体可使用的工具。留空则使用所有已启用的工具。',
advancedToolsTitle: '高级:手选原子工具',
advancedToolsHint: 'Skill 绑定已自动展开 allowed-tools。此处仅用于未打包成 Skill 的内置微工具(如 datetime、delegate_agent。',

View File

@ -0,0 +1,196 @@
/**
* Structured view over an agent's free-text systemPrompt.
*
* Templates and the editor split the prompt into four H2 sections so the UI
* can show "who is this employee" as a tagline on the agent card and edit
* each part separately. The on-disk format stays a single `systemPrompt`
* string these helpers parse it on read and serialize it on save, so the
* backend schema is untouched and prompts authored before this feature
* still load (their full text falls into `extra`).
*
* Section markers are fixed English strings they are an internal protocol,
* never shown to the user. The content inside each section can be in any
* language.
*/
export interface AgentPromptProfile {
role: string
goal: string
backstory: string
extra: string
}
const SECTION_KEYS = ['role', 'goal', 'backstory', 'extra'] as const
type SectionKey = (typeof SECTION_KEYS)[number]
const SECTION_HEADINGS: Record<SectionKey, string> = {
role: 'Role',
goal: 'Goal',
backstory: 'Backstory',
extra: 'Additional Instructions',
}
const HEADING_TO_KEY: Record<string, SectionKey> = {
role: 'role',
goal: 'goal',
backstory: 'backstory',
'additional instructions': 'extra',
}
const SECTION_HEADING_REGEX = /^##\s+(.+?)\s*$/
export function emptyProfile(): AgentPromptProfile {
return { role: '', goal: '', backstory: '', extra: '' }
}
export function isStructuredPrompt(systemPrompt: string | null | undefined): boolean {
if (!systemPrompt) return false
const lines = systemPrompt.split(/\r?\n/)
for (const line of lines) {
const m = line.match(SECTION_HEADING_REGEX)
if (m && HEADING_TO_KEY[m[1].trim().toLowerCase()]) return true
}
return false
}
/**
* 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.
*/
export function parsePrompt(systemPrompt: string | null | undefined): AgentPromptProfile {
const profile = emptyProfile()
if (!systemPrompt) return profile
if (!isStructuredPrompt(systemPrompt)) {
profile.extra = systemPrompt.trim()
return profile
}
const lines = systemPrompt.split(/\r?\n/)
let current: SectionKey | null = null
const buffers: Record<SectionKey, string[]> = {
role: [],
goal: [],
backstory: [],
extra: [],
}
for (const line of lines) {
const m = line.match(SECTION_HEADING_REGEX)
if (m) {
const key = HEADING_TO_KEY[m[1].trim().toLowerCase()]
if (key) {
current = key
continue
}
}
if (current) buffers[current].push(line)
}
for (const key of SECTION_KEYS) {
profile[key] = buffers[key].join('\n').trim()
}
return profile
}
/**
* Serialize a profile back into a systemPrompt string. Empty sections are
* omitted so the stored prompt stays compact.
*/
export function serializePrompt(profile: AgentPromptProfile): string {
const parts: string[] = []
for (const key of SECTION_KEYS) {
const value = (profile[key] || '').trim()
if (!value) continue
parts.push(`## ${SECTION_HEADINGS[key]}\n${value}`)
}
return parts.join('\n\n')
}
/**
* Maximum visible characters for the card tagline. CJK characters count as
* one the rendered string stays roughly the same width as 14 Chinese
* characters or about 28 Latin characters.
*/
export const TAGLINE_MAX_LENGTH = 28
export const TAGLINE_CJK_BUDGET = 14
function visualLength(text: string): number {
let len = 0
for (const ch of text) {
const code = ch.codePointAt(0) || 0
// CJK Unified Ideographs / full-width punctuation count as 2 cells, ASCII as 1.
if (
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3000 && code <= 0x30ff) ||
(code >= 0xff00 && code <= 0xffef)
) {
len += 2
} else {
len += 1
}
}
return len
}
function truncateVisual(text: string, maxCells: number): string {
let used = 0
let out = ''
for (const ch of text) {
const code = ch.codePointAt(0) || 0
const cells =
(code >= 0x4e00 && code <= 0x9fff) ||
(code >= 0x3000 && code <= 0x30ff) ||
(code >= 0xff00 && code <= 0xffef)
? 2
: 1
if (used + cells > maxCells) return out + '…'
out += ch
used += cells
}
return out
}
/**
* Build the one-line `{role} · {goal}` tagline shown on the agent card.
* Falls back gracefully:
* - role + goal "数据分析师 · 把数据变成洞察"
* - role only "数据分析师"
* - goal only goal (truncated)
* - neither + extra first non-empty line of extra (truncated)
* - nothing at all empty string (caller decides fallback)
*/
export function deriveTagline(
profile: AgentPromptProfile,
fallbackDescription?: string | null,
): string {
const role = (profile.role || '').trim().split(/\r?\n/)[0]?.trim() || ''
const goal = (profile.goal || '').trim().split(/\r?\n/)[0]?.trim() || ''
let tagline = ''
if (role && goal) {
tagline = `${role} · ${goal}`
} else if (role) {
tagline = role
} else if (goal) {
tagline = goal
} else if ((profile.extra || '').trim()) {
tagline = profile.extra.trim().split(/\r?\n/)[0]?.trim() || ''
} else if (fallbackDescription && fallbackDescription.trim()) {
tagline = fallbackDescription.trim().split(/\r?\n/)[0]?.trim() || ''
}
// Cap visual width so long goals can't push the card layout around.
const cap = TAGLINE_MAX_LENGTH * 2 // visualLength counts CJK as 2
return visualLength(tagline) > cap ? truncateVisual(tagline, cap) : tagline
}
/**
* Used by the Goal field's live preview to colour the hint when the user
* crosses the recommended length. Returns visual width in CJK-equivalent
* cells so the threshold compares apples to apples regardless of script.
*/
export function taglineVisualWidth(text: string): number {
return Math.ceil(visualLength(text) / 2)
}

View File

@ -60,28 +60,36 @@
class="agent-card mc-surface-card"
:class="{ 'agent-card--disabled': !agent.enabled }"
>
<div class="agent-card__header">
<span class="agent-card__icon">
<SkillIcon :value="agent.icon" :size="36" :fallback="'🤖'" />
<!--
Employee-card layout: avatar, name, one-line tagline, primary
chat action, and a hover-revealed overflow row. Anything that
isn't identity-or-action lives behind the overflow row to keep
the card readable at a glance.
-->
<div class="agent-card__top">
<span class="agent-card__avatar" :class="{ 'agent-card__avatar--off': !agent.enabled }">
<SkillIcon :value="agent.icon" :size="40" :fallback="'🧑‍💼'" />
</span>
<label class="toggle-switch toggle-switch--sm">
<input type="checkbox" :checked="agent.enabled" @change="toggleAgent(agent)" />
<span class="toggle-slider"></span>
</label>
</div>
<div class="agent-card__body">
<h3 class="agent-card__name">{{ agent.name }}</h3>
<p class="agent-card__desc">{{ agent.description || t('agents.messages.noDescription') }}</p>
</div>
<div class="agent-card__meta">
<span class="tag type-tag">{{ agent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
<div class="tags-cell" v-if="agent.tags">
<span v-for="tag in parseTags(agent.tags)" :key="tag" class="tag tag-item">{{ tag }}</span>
<div class="agent-card__identity">
<h3 class="agent-card__name">{{ agent.name }}</h3>
<p class="agent-card__tagline">
{{ agentTagline(agent) || t('agents.messages.noTagline') }}
</p>
</div>
</div>
<div class="agent-card__footer">
<span class="time-label">{{ formatTime(agent.updateTime) }}</span>
<div class="agent-card__actions">
<div class="agent-card__action-row">
<button class="agent-card__primary" @click="goToChat(agent)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
{{ t('agents.actions.chat') }}
</button>
<div class="agent-card__overflow">
<label class="toggle-switch toggle-switch--sm" :title="t('agents.fields.enabled')">
<input type="checkbox" :checked="agent.enabled" @change="toggleAgent(agent)" />
<span class="toggle-slider"></span>
</label>
<button class="action-btn" :title="t('agents.tabs.context')" @click="goToAgentContextFor(agent)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>
@ -246,13 +254,43 @@
<option value="max">{{ t('agents.thinkingLevels.max') }}</option>
</select>
</div>
<!--
Identity triad: role + goal + backstory map to H2 sections in
the stored systemPrompt. The card tagline is derived from
role + goal, so the Goal field shows a live preview to make
the constraint visible while typing.
-->
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.role') }}</label>
<input v-model="profileForm.role" class="form-input" :placeholder="t('agents.placeholders.role')" />
<p class="form-hint">{{ t('agents.fields.roleHint') }}</p>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.goal') }}</label>
<input v-model="profileForm.goal" class="form-input" :placeholder="t('agents.placeholders.goal')" />
<p class="form-hint" :class="{ 'form-hint--warn': taglinePreviewWidth > taglineSoftLimit }">
<span class="form-hint__label">{{ t('agents.fields.taglinePreview') }}</span>
<span class="form-hint__value">{{ taglinePreview || t('agents.messages.noTagline') }}</span>
<span class="form-hint__counter">{{ taglinePreviewWidth }}/{{ taglineSoftLimit }}</span>
</p>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.backstory') }}</label>
<textarea v-model="profileForm.backstory" class="form-textarea" rows="3" :placeholder="t('agents.placeholders.backstory')"></textarea>
<p class="form-hint">{{ t('agents.fields.backstoryHint') }}</p>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('agents.placeholders.description')" />
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.systemPrompt') }}</label>
<textarea v-model="form.systemPrompt" class="form-textarea" rows="5" :placeholder="t('agents.placeholders.systemPrompt')"></textarea>
<details class="advanced-prompt">
<summary class="advanced-prompt__summary">
{{ t('agents.fields.extraInstructions') }}
</summary>
<textarea v-model="profileForm.extra" class="form-textarea" rows="4" :placeholder="t('agents.placeholders.extraInstructions')"></textarea>
<p class="form-hint">{{ t('agents.fields.extraInstructionsHint') }}</p>
</details>
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.tags') }}</label>
@ -269,6 +307,10 @@
<!-- Skills Tab -->
<div v-if="modalTab === 'skills'" class="binding-tab">
<div class="binding-intro">
<span class="binding-intro__kicker">{{ t('agents.binding.skillsKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.skillsTagline') }}</p>
</div>
<p class="binding-hint">{{ t('agents.binding.skillsHint') }}</p>
<div v-if="availableSkills.length === 0" class="binding-empty">{{ t('agents.binding.noSkills') }}</div>
<div v-else class="binding-list">
@ -294,6 +336,10 @@
Skill bindings already auto-expand allowed-tools (§14.2), so
the picker is collapsed by default to reduce noise. -->
<div v-if="modalTab === 'tools'" class="binding-tab">
<div class="binding-intro">
<span class="binding-intro__kicker">{{ t('agents.binding.toolsKicker') }}</span>
<p class="binding-intro__tagline">{{ t('agents.binding.toolsTagline') }}</p>
</div>
<details class="advanced-tools" :open="selectedToolNames.length > 0 || advancedToolsOpen">
<summary class="advanced-tools-summary" @click.prevent="advancedToolsOpen = !advancedToolsOpen">
<span class="advanced-tools-title">
@ -377,6 +423,15 @@ import { agentApi, agentBindingApi, modelApi, skillApi, toolApi, templateApi, ba
import type { Agent } from '@/types/index'
import SkillIcon from '@/components/common/SkillIcon.vue'
import SkillIconPicker from '@/components/common/SkillIconPicker.vue'
import {
emptyProfile,
parsePrompt,
serializePrompt,
deriveTagline,
taglineVisualWidth,
TAGLINE_CJK_BUDGET,
type AgentPromptProfile,
} from '@/utils/agentPromptProfile'
const router = useRouter()
const { t } = useI18n()
@ -435,6 +490,23 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
const form = ref(defaultForm())
const iconPickerVisible = ref(false)
// Identity-triad fields displayed in the basic tab. Kept separate from
// `form.systemPrompt` so the textarea state stays predictable while the
// user types we only flatten back to a single prompt at save time.
const profileForm = ref<AgentPromptProfile>(emptyProfile())
const taglineSoftLimit = TAGLINE_CJK_BUDGET
const taglinePreview = computed(() => deriveTagline(profileForm.value, form.value.description))
const taglinePreviewWidth = computed(() => taglineVisualWidth(taglinePreview.value))
/** Tagline shown on each agent card derived from the stored systemPrompt
* and (as a fallback) the agent's description. Pure function, safe to call
* in the template. */
function agentTagline(agent: Agent): string {
const profile = parsePrompt(agent.systemPrompt)
return deriveTagline(profile, agent.description)
}
const filteredAgents = computed(() => {
let list = agents.value
if (searchText.value) {
@ -508,17 +580,6 @@ async function loadAvailableModels() {
}
}
function parseTags(tags: string): string[] {
return tags.split(',').map(s => s.trim()).filter(Boolean)
}
function formatTime(time?: string): string {
if (!time) return '-'
const d = new Date(time)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function openCreateModal() {
// Show template selector first
showTemplateSelector.value = true
@ -529,6 +590,7 @@ function openBlankCreateModal() {
showTemplateSelector.value = false
editingAgent.value = null
form.value = defaultForm()
profileForm.value = emptyProfile()
modalTab.value = 'basic'
selectedSkillIds.value = []
selectedToolNames.value = []
@ -600,6 +662,7 @@ async function openEditModal(agent: Agent) {
enabled: agent.enabled,
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
}
profileForm.value = parsePrompt(agent.systemPrompt)
modalTab.value = 'basic'
showModal.value = true
@ -643,12 +706,18 @@ function closeModal() {
async function saveAgent() {
try {
// Flatten the structured profile back to a single systemPrompt before
// sending to the backend the schema is unchanged, only the editor
// exposes the H2 sections to the user.
const serialized = serializePrompt(profileForm.value)
const payload = { ...form.value, systemPrompt: serialized }
let agentId: string | number
if (editingAgent.value) {
await agentApi.update(editingAgent.value.id, form.value)
await agentApi.update(editingAgent.value.id, payload)
agentId = editingAgent.value.id
} else {
const res: any = await agentApi.create(form.value)
const res: any = await agentApi.create(payload)
agentId = res.data?.id
}
@ -685,16 +754,15 @@ async function deleteAgent(agent: Agent) {
}
}
function goToAgentContext() {
const agentId = editingAgent.value?.id
closeModal()
router.push({ path: '/settings/agent-context', query: agentId ? { agentId: String(agentId) } : {} })
}
function goToAgentContextFor(agent: Agent) {
router.push({ path: '/settings/agent-context', query: { agentId: String(agent.id) } })
}
/** Card primary action: open a chat with this agent. */
function goToChat(agent: Agent) {
router.push({ path: '/chat', query: { agentId: String(agent.id) } })
}
async function toggleAgent(agent: Agent) {
try {
await agentApi.update(agent.id, { ...agent, enabled: !agent.enabled })
@ -838,70 +906,103 @@ html.dark .live-pill {
opacity: 0.55;
}
.agent-card__header {
/* Employee-card layout identity row on top, primary action row below.
The card answers one question: "Who is this and how do I talk to them?" */
.agent-card__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-width: 0;
}
.agent-card__icon {
font-size: 36px;
width: 52px;
height: 52px;
.agent-card__avatar {
width: 56px;
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background: var(--mc-primary-bg);
border-radius: 14px;
border-radius: 18px;
flex-shrink: 0;
font-size: 32px;
transition: filter 0.18s;
}
.agent-card__body {
.agent-card__avatar--off {
filter: grayscale(0.85);
}
.agent-card__identity {
flex: 1;
min-height: 0;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.agent-card__name {
font-size: 16px;
font-size: 17px;
font-weight: 700;
color: var(--mc-text-primary);
margin: 0 0 4px;
letter-spacing: -0.02em;
}
.agent-card__desc {
font-size: 13px;
color: var(--mc-text-tertiary);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
letter-spacing: -0.02em;
white-space: nowrap;
overflow: hidden;
line-height: 1.5;
text-overflow: ellipsis;
}
.agent-card__meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
/* The tagline IS the soul of the card. Keep it on one line so the eye reads
it as a single statement of identity never wrap, never grow. */
.agent-card__tagline {
font-size: 13.5px;
color: var(--mc-text-secondary);
margin: 0;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
letter-spacing: -0.005em;
}
.agent-card__footer {
.agent-card__action-row {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 10px;
gap: 8px;
margin-top: auto;
padding-top: 12px;
border-top: 1px solid var(--mc-border-light);
}
.agent-card__actions {
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s;
.agent-card__primary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: var(--mc-bg-sunken);
color: var(--mc-text-primary);
border: 1px solid var(--mc-border);
border-radius: 999px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.agent-card__primary:hover {
border-color: var(--mc-primary);
color: var(--mc-primary);
background: var(--mc-primary-bg);
}
.agent-card:hover .agent-card__actions {
.agent-card__overflow {
display: flex;
align-items: center;
gap: 4px;
opacity: 0;
transition: opacity 0.18s;
}
.agent-card:hover .agent-card__overflow,
.agent-card:focus-within .agent-card__overflow {
opacity: 1;
}
@ -909,32 +1010,8 @@ html.dark .live-pill {
.toggle-switch--sm .toggle-slider::before { width: 12px; height: 12px; }
.toggle-switch--sm input:checked + .toggle-slider::before { transform: translateX(14px); }
/* Context link card */
.context-link-card {
display: flex;
align-items: center;
gap: 14px;
padding: 18px;
cursor: pointer;
border: 1px solid var(--mc-border);
border-radius: 12px;
transition: all 0.15s;
}
.context-link-card:hover {
border-color: var(--mc-primary);
background: var(--mc-primary-bg);
}
.context-link-card__icon { font-size: 28px; }
.context-link-card__info { flex: 1; display: flex; flex-direction: column; gap: 2px; }
.context-link-card__title { font-size: 14px; font-weight: 600; color: var(--mc-text-primary); }
.context-link-card__desc { font-size: 12px; color: var(--mc-text-tertiary); }
.context-link-card__arrow { color: var(--mc-text-tertiary); flex-shrink: 0; }
.tag { padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 500; }
.type-tag { background: var(--mc-primary-bg); color: var(--mc-primary); }
.tag-item { background: var(--mc-bg-sunken); color: var(--mc-text-secondary); }
.tags-cell { display: flex; gap: 4px; flex-wrap: wrap; }
.time-label { font-size: 13px; color: var(--mc-text-tertiary); }
.text-muted { color: var(--mc-text-tertiary); }
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; flex-shrink: 0; }
@ -984,6 +1061,32 @@ html.dark .live-pill {
/* Binding Tab */
.binding-tab { min-height: 200px; }
.binding-hint { font-size: 13px; color: var(--mc-text-tertiary); margin: 0 0 16px; }
/* Tools/Skills semantic intro names what the user is about to bind so the
distinction between "atomic tool" and "trained workflow" is unmissable. */
.binding-intro {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
margin: 0 0 14px;
background: var(--mc-bg-muted);
border-left: 3px solid var(--mc-primary);
border-radius: 8px;
}
.binding-intro__kicker {
font-size: 11px;
font-weight: 700;
color: var(--mc-primary);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.binding-intro__tagline {
font-size: 13px;
color: var(--mc-text-secondary);
line-height: 1.5;
margin: 0;
}
.binding-empty { padding: 40px; text-align: center; color: var(--mc-text-tertiary); font-size: 14px; }
.binding-list { display: flex; flex-direction: column; gap: 6px; }
.binding-item {
@ -1034,13 +1137,75 @@ html.dark .live-pill {
}
.provider-pref-add-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); border-style: solid; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
/* minmax(0, 1fr) prevents nowrap children (e.g. the tagline preview)
from forcing the grid track wider than the modal body. */
.form-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
.form-group { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.form-group.full-width { grid-column: 1 / -1; }
.form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); }
.form-input, .form-textarea { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; transition: border-color 0.15s; background: var(--mc-bg-sunken); }
.form-input:focus, .form-textarea:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }
.form-textarea { resize: vertical; font-family: inherit; }
/* Inline guidance below an input. Used for the role/goal/backstory triad
so the constraint (one short line, etc.) is visible while typing. */
.form-hint {
font-size: 12px;
color: var(--mc-text-tertiary);
line-height: 1.5;
margin: 2px 0 0;
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: baseline;
}
.form-hint__label {
color: var(--mc-text-tertiary);
}
.form-hint__value {
color: var(--mc-text-secondary);
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.form-hint__counter {
font-variant-numeric: tabular-nums;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px;
color: var(--mc-text-tertiary);
}
.form-hint--warn .form-hint__value,
.form-hint--warn .form-hint__counter {
color: hsl(20, 78%, 48%);
}
/* Collapsible advanced-instructions block the everyday user fills the
triad and never opens this; power users can append free-form additions. */
.advanced-prompt { border: 1px dashed var(--mc-border); border-radius: 10px; padding: 0; }
.advanced-prompt[open] { padding: 12px 14px; }
.advanced-prompt__summary {
list-style: none;
cursor: pointer;
padding: 10px 14px;
font-size: 13px;
font-weight: 600;
color: var(--mc-text-secondary);
user-select: none;
}
.advanced-prompt__summary::-webkit-details-marker { display: none; }
.advanced-prompt__summary::before {
content: '▸';
display: inline-block;
margin-right: 6px;
color: var(--mc-text-tertiary);
font-size: 11px;
transition: transform 0.15s;
}
.advanced-prompt[open] > .advanced-prompt__summary { padding: 0 0 8px; border-bottom: 1px solid var(--mc-border-light); margin-bottom: 8px; }
.advanced-prompt[open] > .advanced-prompt__summary::before { transform: rotate(90deg); }
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 16px 24px; border-top: 1px solid var(--mc-border-light); }
@media (max-width: 900px) {