From 18fe95a711a715102bdc3150e41b69b875b7757c Mon Sep 17 00:00:00 2001 From: awol2005ex Date: Fri, 28 Aug 2026 12:08:44 +0800 Subject: [PATCH] feat(agents): add agent hint field support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增多语言国际化的智能体提示文案配置 2. 在智能体编辑页面添加提示输入框并支持前后端数据交互 3. 实现从智能体markdown文件读取hint元数据并在聊天界面展示 4. 调整聊天界面布局适配提示文本展示 --- .../codecli/portal/web/WebController.java | 54 ++++++++++++++++ .../web/settings/AgentSettingsController.java | 61 +++++++++++++++++-- .../src/main/resources/static/css/app.css | 6 +- .../src/main/resources/static/i18n/en.json | 3 + .../src/main/resources/static/i18n/zh-CN.json | 3 + .../main/resources/static/js/app-history.js | 40 ++++++++++++ .../static/js/app-settings-agents.js | 7 ++- .../src/main/resources/static/web.html | 8 ++- 8 files changed, 171 insertions(+), 11 deletions(-) diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebController.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebController.java index 14b6e854..bbf8c5a0 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebController.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/WebController.java @@ -1218,6 +1218,11 @@ public class WebController { item.put("name", definition.getName()); item.put("description", definition.getDescription()); item.put("type", "subagent"); + // Try to read hint from the agent's markdown file + String hint = readHintFromAgent(definition.getName()); + if (hint != null && !hint.isEmpty()) { + item.put("hint", hint); + } data.add(item); } @@ -1252,6 +1257,55 @@ public class WebController { return Result.succeed(data); } + /** + * 从智能体的 markdown 文件中读取 hint 字段。 + */ + private String readHintFromAgent(String name) { + if (name == null || name.isEmpty()) return ""; + try { + // Check user scope first, then workspace + Path userRoot = Paths.get(AgentFlags.getUserHome(), AgentFlags.getHarnessAgents()).toAbsolutePath().normalize(); + Path userFile = userRoot.resolve(name + ".md").normalize(); + if (userFile.startsWith(userRoot) && Files.exists(userFile) && !Files.isDirectory(userFile)) { + String md = new String(Files.readAllBytes(userFile)); + String hint = extractFrontMatterValue(md, "hint"); + if (hint != null && !hint.isEmpty()) return hint; + } + Path workspaceRoot = Paths.get(engine().getWorkspace(), AgentFlags.getHarnessAgents()).toAbsolutePath().normalize(); + Path workspaceFile = workspaceRoot.resolve(name + ".md").normalize(); + if (workspaceFile.startsWith(workspaceRoot) && Files.exists(workspaceFile) && !Files.isDirectory(workspaceFile)) { + String md = new String(Files.readAllBytes(workspaceFile)); + String hint = extractFrontMatterValue(md, "hint"); + if (hint != null && !hint.isEmpty()) return hint; + } + } catch (Exception e) { + // Silently ignore read errors + } + return ""; + } + + /** + * Extract a specific key's value from markdown front matter. + */ + private String extractFrontMatterValue(String markdown, String key) { + if (markdown == null) return ""; + String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); + java.util.regex.Matcher matcher = java.util.regex.Pattern.compile( + "^" + key + "\\s*:\\s*(.*)$", + java.util.regex.Pattern.MULTILINE + ).matcher(normalized); + if (matcher.find()) { + String value = matcher.group(1).trim(); + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } else if (value.startsWith("'") && value.endsWith("'") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + return value; + } + return ""; + } + /** * 聊天输入入口:解析请求参数后路由到 WebGate 处理。 *

接收用户输入的文本消息、附件文件、模型选择、推理选项和会话标识, diff --git a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/AgentSettingsController.java b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/AgentSettingsController.java index 03acf80d..2a249084 100644 --- a/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/AgentSettingsController.java +++ b/soloncode-cli/src/main/java/org/noear/solon/codecli/portal/web/settings/AgentSettingsController.java @@ -48,6 +48,7 @@ public class AgentSettingsController extends BaseSettingsController { private static final int MAX_FILE_SIZE = 512 * 1024; private static final String BUILTIN_RESOURCE_BASE = "META-INF/solon/ai/harness/"; private static final String[] BUILTIN_NAMES = {"general", "explore", "bash", "plan", "git-summary"}; + private static final Pattern HINT_PATTERN = Pattern.compile("^hint\\s*:\\s*(.*)$", Pattern.MULTILINE); private static final Pattern TOP_LEVEL_KEY_PATTERN = Pattern.compile("^[A-Za-z_][A-Za-z0-9_-]*\\s*:.*$"); private static final Logger LOG = LoggerFactory.getLogger(AgentSettingsController.class); @@ -109,7 +110,7 @@ public class AgentSettingsController extends BaseSettingsController { String markdown = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); try { AgentDefinition definition = parseAndValidate(markdown, name); - Map data = toItem(definition, normalizeScope(scope), false); + Map data = toItem(definition, normalizeScope(scope), false, markdown); data.put("systemPrompt", definition.getSystemPrompt()); data.put("editable", true); return Result.succeed(data); @@ -119,6 +120,7 @@ public class AgentSettingsController extends BaseSettingsController { data.put("description", ""); data.put("tools", Collections.emptyList()); data.put("systemPrompt", extractSystemPrompt(markdown)); + data.put("hint", extractFrontMatterValue(markdown, "hint")); data.put("scope", normalizeScope(scope)); data.put("editable", true); data.put("valid", false); @@ -238,7 +240,9 @@ public class AgentSettingsController extends BaseSettingsController { String description = root.get("description").getString(); String systemPrompt = root.get("systemPrompt").getString(); String model = root.get("model").getString(); + String hint = root.get("hint").getString(); if (model != null) model = model.trim(); + if (hint != null) hint = hint.trim(); List tools = readStringList(root.get("tools")); String originalName = root.get("originalName").getString(); String originalScopeValue = root.get("originalScope").getString(); @@ -280,7 +284,7 @@ public class AgentSettingsController extends BaseSettingsController { } } - String markdown = buildMarkdown(name, description, tools, model, systemPrompt, originalMarkdown); + String markdown = buildMarkdown(name, description, tools, model, systemPrompt, originalMarkdown, hint); if (markdown.getBytes(StandardCharsets.UTF_8).length > MAX_FILE_SIZE) return Result.failure("智能体文件最大允许 512 KB"); parseAndValidate(markdown, name); @@ -336,13 +340,14 @@ public class AgentSettingsController extends BaseSettingsController { if (Files.size(file) > MAX_FILE_SIZE) throw new IllegalArgumentException("文件超过 512 KB"); String markdown = new String(Files.readAllBytes(file), StandardCharsets.UTF_8); AgentDefinition definition = parseAndValidate(markdown, name); - item = toItem(definition, scope, false); + item = toItem(definition, scope, false, markdown); item.put("sourceScope", scope); item.put("editable", true); } catch (Exception e) { item = new LinkedHashMap<>(); item.put("name", name); item.put("description", e.getMessage()); + item.put("hint", ""); item.put("scope", scope); item.put("sourceScope", scope); item.put("builtin", false); @@ -368,6 +373,10 @@ public class AgentSettingsController extends BaseSettingsController { } private Map toItem(AgentDefinition definition, String scope, boolean builtin) { + return toItem(definition, scope, builtin, null); + } + + private Map toItem(AgentDefinition definition, String scope, boolean builtin, String markdown) { Map item = new LinkedHashMap<>(); item.put("name", definition.getName()); item.put("description", definition.getDescription()); @@ -379,9 +388,35 @@ public class AgentSettingsController extends BaseSettingsController { item.put("scope", scope); item.put("builtin", builtin); item.put("valid", true); + if (markdown != null) { + item.put("hint", extractFrontMatterValue(markdown, "hint")); + } return item; } + /** + * Extract a specific key's value from markdown front matter. + */ + private String extractFrontMatterValue(String markdown, String key) { + if (markdown == null) return ""; + String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); + java.util.regex.Matcher matcher = Pattern.compile( + "^" + key + "\\s*:\\s*(.*)$", + Pattern.MULTILINE + ).matcher(normalized); + if (matcher.find()) { + String value = matcher.group(1).trim(); + // Remove surrounding quotes if present + if (value.startsWith("\"") && value.endsWith("\"") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } else if (value.startsWith("'") && value.endsWith("'") && value.length() >= 2) { + value = value.substring(1, value.length() - 1); + } + return value; + } + return ""; + } + private AgentDefinition findBuiltin(String name) { return loadBuiltinDefinitions().get(name); } @@ -423,9 +458,15 @@ public class AgentSettingsController extends BaseSettingsController { private String buildMarkdown(String name, String description, List tools, String model, String systemPrompt, String originalMarkdown) { + return buildMarkdown(name, description, tools, model, systemPrompt, originalMarkdown, null); + } + + private String buildMarkdown(String name, String description, List tools, + String model, String systemPrompt, String originalMarkdown, String hint) { // model != null 表示前端明确接管模型设置(含清空);null 表示保留旧 front matter 中的 model boolean takeoverModel = model != null; - List preservedLines = extractPreservedFrontMatter(originalMarkdown, takeoverModel); + boolean takeoverHint = hint != null; + List preservedLines = extractPreservedFrontMatter(originalMarkdown, takeoverModel, takeoverHint); StringBuilder markdown = new StringBuilder(); markdown.append("---\n"); markdown.append("name: ").append(ONode.ofBean(name).toJson()).append('\n'); @@ -434,6 +475,9 @@ public class AgentSettingsController extends BaseSettingsController { if (!Assert.isEmpty(model)) { markdown.append("model: ").append(ONode.ofBean(model).toJson()).append('\n'); } + if (!Assert.isEmpty(hint)) { + markdown.append("hint: ").append(ONode.ofBean(hint).toJson()).append('\n'); + } for (String line : preservedLines) { markdown.append(line).append('\n'); } @@ -479,10 +523,14 @@ public class AgentSettingsController extends BaseSettingsController { } private List extractPreservedFrontMatter(String markdown) { - return extractPreservedFrontMatter(markdown, true); + return extractPreservedFrontMatter(markdown, true, true); } private List extractPreservedFrontMatter(String markdown, boolean takeoverModel) { + return extractPreservedFrontMatter(markdown, takeoverModel, true); + } + + private List extractPreservedFrontMatter(String markdown, boolean takeoverModel, boolean takeoverHint) { List result = new ArrayList<>(); if (Assert.isEmpty(markdown)) return result; String normalized = markdown.replace("\r\n", "\n").replace('\r', '\n'); @@ -497,7 +545,8 @@ public class AgentSettingsController extends BaseSettingsController { if (topLevel) { String key = line.substring(0, line.indexOf(':')).trim(); skip = "name".equals(key) || "description".equals(key) - || "tools".equals(key) || (takeoverModel && "model".equals(key)); + || "tools".equals(key) || (takeoverModel && "model".equals(key)) + || (takeoverHint && "hint".equals(key)); } if (!skip) result.add(line); } diff --git a/soloncode-cli/src/main/resources/static/css/app.css b/soloncode-cli/src/main/resources/static/css/app.css index c05118ea..64175c8f 100644 --- a/soloncode-cli/src/main/resources/static/css/app.css +++ b/soloncode-cli/src/main/resources/static/css/app.css @@ -341,7 +341,8 @@ body { .newchat-avatar img { width: 100px; height: 100px; object-fit: contain; border-radius: 24px; } @keyframes blob-spin { to { transform: rotate(360deg); } } .newchat-title { font-size: var(--fs-4xl); font-weight: 700; margin-bottom: 10px; } -.newchat-sub { color: var(--text-secondary); font-size: var(--fs-lg); margin-bottom: 36px; } +.newchat-sub { color: var(--text-secondary); font-size: var(--fs-lg); margin-bottom: 12px; } +.newchat-hint { color: var(--text-primary); font-size: var(--fs); margin-bottom: 36px; text-align: center; max-width: 520px; line-height: 1.6; } .newchat-input-box { width: 100%; max-width: 780px; background: var(--bg-input-box); border: 1px solid var(--border-input); border-radius: 16px; box-shadow: var(--shadow-lg); display: flex; flex-direction: column; transition: all 0.25s; } .newchat-input-box:focus-within { border-color: var(--accent); box-shadow: var(--shadow-lg), 0 0 0 4px rgba(79,110,247,0.08); } .newchat-input-box textarea { border: none; outline: none; resize: none; background: transparent; padding: 18px 20px 8px; font-family: var(--font-sans); font-size: var(--fs-lg); color: var(--text-primary); min-height: calc(54px * var(--font-scale)); max-height: calc(140px * var(--font-scale)); line-height: 1.6; } @@ -2715,7 +2716,8 @@ body.memory-active .main-header { display: none; } .input-wrap { padding: 8px 12px 16px; } .newchat-title { font-size: calc(22px * var(--font-scale)); } .onboarding-home-title { font-size: calc(22px * var(--font-scale)); } - .newchat-sub { font-size: var(--fs-base); margin-bottom: 24px; } + .newchat-sub { font-size: var(--fs-base); margin-bottom: 12px; } + .newchat-hint { font-size: var(--fs-sm); margin-bottom: 24px; } .newchat-avatar { width: 80px; height: 80px; } .newchat-avatar img { width: 80px; height: 80px; } .main-header { padding: 12px 16px 10px; } diff --git a/soloncode-cli/src/main/resources/static/i18n/en.json b/soloncode-cli/src/main/resources/static/i18n/en.json index 702ca344..5cb7854e 100644 --- a/soloncode-cli/src/main/resources/static/i18n/en.json +++ b/soloncode-cli/src/main/resources/static/i18n/en.json @@ -50,6 +50,9 @@ "agents.modelFollowDefault": "Follow chat", "agents.modelSelect": "Select run model", "agents.modelHint": "Leave empty to run this agent with the chat's selected model.", + "agents.hint": "Hint", + "agents.hintPlaceholder": "Optional. A short description of the agent's purpose, shown in the selector dropdown", + "agents.hintHint": "When set, this text will appear as a hint in the agent selector to help users understand the agent's purpose.", "agents.systemPrompt": "System Prompt", "agents.systemPromptRequired": "Please enter a system prompt", "agents.title": "Agents", diff --git a/soloncode-cli/src/main/resources/static/i18n/zh-CN.json b/soloncode-cli/src/main/resources/static/i18n/zh-CN.json index 92552e91..051fb5ad 100644 --- a/soloncode-cli/src/main/resources/static/i18n/zh-CN.json +++ b/soloncode-cli/src/main/resources/static/i18n/zh-CN.json @@ -50,6 +50,9 @@ "agents.modelFollowDefault": "跟随对话", "agents.modelSelect": "选择运行模型", "agents.modelHint": "留空时该智能体跟随对话选择的模型运行。", + "agents.hint": "提示(hint)", + "agents.hintPlaceholder": "选填,简短描述该智能体的用途,在选择器下拉列表中显示", + "agents.hintHint": "设置后,在智能体选择器中会显示为提示文本,帮助用户了解该智能体的用途。", "agents.systemPrompt": "系统提示词", "agents.systemPromptRequired": "请填写系统提示词", "agents.title": "智能体", diff --git a/soloncode-cli/src/main/resources/static/js/app-history.js b/soloncode-cli/src/main/resources/static/js/app-history.js index 27e10339..c00b2b97 100644 --- a/soloncode-cli/src/main/resources/static/js/app-history.js +++ b/soloncode-cli/src/main/resources/static/js/app-history.js @@ -569,6 +569,9 @@ function loadCommands() { commandList = resp.data || []; commandsLoaded = true; if (typeof renderAgentUI === 'function') renderAgentUI(); + if (typeof updateAgentHint === 'function') { + updateAgentHint(getSelectedAgent()); + } } catch (e) {} }); } @@ -1716,10 +1719,47 @@ function selectAgent(agentName) { var sid = getSessionKey(); sessionAgentMap[sid] = agentName || ''; renderAgentUI(); + updateAgentHint(agentName); // 选择时调后端记住(与模型选择器保持一致) postAgentSelect({ sessionId: sid, agentName: agentName || '' }); } +function updateAgentHint(agentName) { + var $hint = $('#newChatHint'); + var $avatar = $('.newchat-avatar'); + var $title = $('#newChatTitle'); + var $sub = $('#newChatSub'); + if (!agentName) { + $avatar.show(); + $title.show(); + $sub.show(); + $hint.hide(); + return; + } + // 从 commandList 中查找 hint + var hint = ''; + for (var i = 0; i < commandList.length; i++) { + var item = commandList[i]; + if (item.type === 'subagent' && item.name === agentName && item.hint) { + hint = item.hint; + break; + } + } + if (hint) { + $avatar.hide(); + $title.hide(); + $sub.hide(); + // 渲染 markdown + var html = marked.parse(hint); + $hint.html(html).show(); + } else { + $avatar.show(); + $title.show(); + $sub.show(); + $hint.hide(); + } +} + function initAgentSelector(selectorId, currentId, dropdownId) { var $selector = $('#' + selectorId); var $current = $('#' + currentId); diff --git a/soloncode-cli/src/main/resources/static/js/app-settings-agents.js b/soloncode-cli/src/main/resources/static/js/app-settings-agents.js index 6b0afabc..fb6624dd 100644 --- a/soloncode-cli/src/main/resources/static/js/app-settings-agents.js +++ b/soloncode-cli/src/main/resources/static/js/app-settings-agents.js @@ -277,6 +277,7 @@ sourceScope = null; $('#agentsName').val('').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsDescription').val('').prop('readOnly', false).removeClass('readonly-gray'); + $('#agentsHint').val('').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsSystemPrompt').val('').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsToolsBtn').prop('disabled', false); $('#agentsToolsBtn').removeClass('is-open'); @@ -305,6 +306,7 @@ showFormView(data.valid === false ? I18n.t('agents.repairConfig') : I18n.t('agents.formTitle.edit'), true); $('#agentsName').val(name).prop('readOnly', true).addClass('readonly-gray'); $('#agentsDescription').val(data.description || '').prop('readOnly', false).removeClass('readonly-gray'); + $('#agentsHint').val(data.hint || '').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsSystemPrompt').val(data.systemPrompt || '').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsToolsBtn').prop('disabled', false); $('#agentsToolsBtn').removeClass('is-open'); @@ -327,7 +329,7 @@ showFormView(I18n.t('agents.copyAgent'), false); $('#agentsFormActions').hide(); $('#agentsName').prop('readOnly', false).removeClass('readonly-gray').focus().select(); - $('#agentsDescription, #agentsSystemPrompt').prop('readOnly', false).removeClass('readonly-gray'); + $('#agentsDescription, #agentsHint, #agentsSystemPrompt').prop('readOnly', false).removeClass('readonly-gray'); $('#agentsToolsBtn').prop('disabled', false); $('#agentsToolsBtn').removeClass('is-open'); $('#agentsToolsSelector').hide().removeClass('is-open'); @@ -413,12 +415,13 @@ var name = $('#agentsName').val().trim(); var scope = $('#agentsScope').val() || 'user'; var description = $('#agentsDescription').val().trim(); + var hint = $('#agentsHint').val().trim(); var systemPrompt = $('#agentsSystemPrompt').val().trim(); if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name)) { showToast(I18n.t('agents.nameInvalid'), 'error'); return; } if (!description) { showToast(I18n.t('agents.descriptionRequired'), 'error'); return; } if (!systemPrompt) { showToast(I18n.t('agents.systemPromptRequired'), 'error'); return; } var model = ($('#agentsModel').val() || '').trim(); - var body = { name: name, scope: scope, description: description, tools: selectedTools, model: model, systemPrompt: systemPrompt }; + var body = { name: name, scope: scope, description: description, tools: selectedTools, model: model, hint: hint, systemPrompt: systemPrompt }; if (sourceName && sourceScope) { body.sourceName = sourceName; body.sourceScope = sourceScope; body.sourceBuiltin = builtinSource; } var isEdit = !!editName && !builtinSource; if (isEdit) { body.originalName = editName; body.originalScope = editScope; } diff --git a/soloncode-cli/src/main/resources/static/web.html b/soloncode-cli/src/main/resources/static/web.html index df160368..5fab6754 100644 --- a/soloncode-cli/src/main/resources/static/web.html +++ b/soloncode-cli/src/main/resources/static/web.html @@ -100,7 +100,8 @@ SolonCode

你好,我是 SolonCode !
-
需要什么帮助吗?
+
需要什么帮助吗?
+
@@ -1154,6 +1155,11 @@
留空时该智能体跟随对话选择的模型运行。
+
+ + +
设置后,在智能体选择器中会显示为提示文本,帮助用户了解该智能体的用途。
+
-- Gitee