mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(agents): role/goal/backstory profile, employee-card tagline, 5 career templates
This commit is contained in:
parent
1aa7787ced
commit
2a8f6774e7
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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).',
|
||||
|
||||
@ -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)。',
|
||||
|
||||
196
mateclaw-ui/src/utils/agentPromptProfile.ts
Normal file
196
mateclaw-ui/src/utils/agentPromptProfile.ts
Normal 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)
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user