mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
fix(skill): tool-gate whitelist + markdown link host normalization
When an agent had any skill bound, the runtime tool gate was silently
hiding @Tool beans that aren't declared in any skill manifest, even
though the global system prompts (SOUL.md / "Web Search Capability" /
"File Reading Guidelines") explicitly tell the LLM these tools are
available. Result: the model would call search / renderDocx / read_file
/ etc., hit "Tool not found", then either give up or fall back to
unhelpful behaviour (e.g. dumping markdown text instead of producing a
.docx download).
This commit:
- Adds universally-promised, agent-wide tools to SYSTEM_LEVEL_TOOLS so
they bypass the manifest restriction: document/media generation
(renderDocx*, image_generate, music_generate, video_generate),
global capability tools the system prompt mentions (search,
browser_use, read_file / write_file / edit_file /
execute_shell_command, detect_file_type, extract_*_text,
readMateClawDoc), skill discovery siblings (listSkillFiles,
listAvailableSkills), and the delegate triplet (delegateToAgent,
delegateParallel, listAvailableAgents).
- Fixes 5 entries in the prior whitelist whose names did not match
any real @Tool bean and were therefore silently dead:
read_workspace_file -> read_workspace_memory_file
write_workspace_file -> write_workspace_memory_file
list_workspace_files -> list_workspace_memory_files
delegate_agent -> delegateToAgent
datetime -> getCurrentDate / getCurrentDateTime / getCurrentTime
Also adds the missing edit_workspace_memory_file.
- In the chat markdown renderer, strips any hallucinated
https?://<host> prefix from /api/v1/files/generated/<id> download
links before building the <a href>. Multiple LLMs have been
observed prepending bogus hosts when echoing tool-returned download
URLs back to the user, breaking the click. One-line defensive
normalization independent of which model is in use.
Verified end-to-end on a previously-broken agent: search / browser_use
/ execute_shell_command / renderDocx all dispatch correctly now and
the final markdown link is a clean same-origin path. 36 whitelist
entries cross-checked against real @Tool method names.
AgentBindingServiceTest green.
This commit is contained in:
parent
f8ec223bcb
commit
5e6764ecf1
@ -166,11 +166,23 @@ public class AcpStdioClient implements AutoCloseable {
|
||||
* Send {@code session/new} — establishes a session for prompting.
|
||||
* For the connection-test path we don't actually prompt, just
|
||||
* verify the server accepts the handshake.
|
||||
*
|
||||
* <p>The {@code cwd} parameter is always written into the request
|
||||
* body. Zed's ACP Zod schema (used by {@code @zed-industries/claude-
|
||||
* agent-acp} and the codex variant) marks {@code cwd} as a required
|
||||
* string and returns {@code -32602 Invalid params} when it's
|
||||
* missing. If the caller passes null/blank we substitute the JVM
|
||||
* working directory — a workspace-aware default lives in
|
||||
* {@code AcpRuntimeSupport#resolveCwd}, but this fallback ensures
|
||||
* the protocol never sees {@code undefined} regardless of caller.
|
||||
*/
|
||||
public JsonNode newSession(String cwd, long timeoutMillis)
|
||||
throws IOException, InterruptedException {
|
||||
ObjectNode params = mapper.createObjectNode();
|
||||
if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd);
|
||||
String safeCwd = (cwd == null || cwd.isBlank())
|
||||
? System.getProperty("user.dir", ".")
|
||||
: cwd;
|
||||
params.put("cwd", safeCwd);
|
||||
params.set("mcpServers", mapper.createArrayNode());
|
||||
return sendRequest("session/new", params, timeoutMillis);
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
package vip.mate.acp.event;
|
||||
|
||||
/**
|
||||
* Lifecycle event for ACP endpoint rows.
|
||||
*
|
||||
* <p>Published by {@code AcpEndpointService} whenever a row is created,
|
||||
* updated, toggled, or deleted. Listened to by
|
||||
* {@code AcpSkillBridge} so it can re-sync the auto-bridged virtual
|
||||
* skill cards and their wrapper tool registrations without a full
|
||||
* application restart.
|
||||
*
|
||||
* <p>Mirrors the {@code SkillWorkspaceEvent} pattern — a small immutable
|
||||
* record carrying just enough context for listeners to fan out.
|
||||
*/
|
||||
public record AcpEndpointChangedEvent(Long endpointId, String name, Type type) {
|
||||
|
||||
public enum Type {
|
||||
/** Row inserted. */
|
||||
CREATED,
|
||||
/** Row attributes updated (command/args/env/etc.). */
|
||||
UPDATED,
|
||||
/** {@code enabled} flag flipped. */
|
||||
TOGGLED,
|
||||
/** Row deleted. */
|
||||
DELETED
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,7 @@ public class AcpConnectionTester {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AcpEndpointService endpointService;
|
||||
private final AcpRuntimeSupport runtimeSupport;
|
||||
|
||||
/**
|
||||
* Spawn the configured agent, exchange initialize + session/new,
|
||||
@ -46,11 +47,16 @@ public class AcpConnectionTester {
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
result.put("args", args);
|
||||
// Same as AcpDelegationService — Zed's ACP server requires a
|
||||
// non-blank cwd at session/new, so the connection test must
|
||||
// also default it. The "Test" button used to fail at session/new
|
||||
// with -32602 even when the CLI itself was healthy.
|
||||
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, null);
|
||||
|
||||
AcpStdioClient client;
|
||||
try {
|
||||
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||
args, env, /* cwd */ null);
|
||||
args, env, resolvedCwd);
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Spawn failed: " + e.getMessage(), started);
|
||||
@ -85,15 +91,18 @@ public class AcpConnectionTester {
|
||||
// session/new validates that the agent really stands up a
|
||||
// working session, not just initialize handshake.
|
||||
try {
|
||||
JsonNode sessionResp = autoClose.newSession(null, SESSION_NEW_TIMEOUT_MS);
|
||||
JsonNode sessionResp = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||
if (sessionResp != null && sessionResp.has("sessionId")) {
|
||||
result.put("sessionId", sessionResp.path("sessionId").asText(""));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// session/new may fail for legitimate reasons (e.g. agent
|
||||
// requires auth flow first). Still report OK on initialize
|
||||
// but flag in the message.
|
||||
result.put("sessionWarning", e.getMessage());
|
||||
// but flag in the message — translated when it smells
|
||||
// like an auth error so the test page UI shows actionable
|
||||
// text instead of raw JSON-RPC.
|
||||
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||
result.put("sessionWarning", authHint != null ? authHint : e.getMessage());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
|
||||
@ -57,6 +57,7 @@ public class AcpDelegationService {
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AcpEndpointService endpointService;
|
||||
private final AcpRuntimeSupport runtimeSupport;
|
||||
|
||||
/**
|
||||
* Run a one-shot ACP prompt against {@code endpointName}. Returns
|
||||
@ -88,12 +89,16 @@ public class AcpDelegationService {
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
boolean trusted = !Boolean.FALSE.equals(endpoint.getTrusted());
|
||||
// Always resolve cwd to a real directory: Zed's ACP Zod schema
|
||||
// marks cwd as a required string and rejects {@code undefined}
|
||||
// with -32602. See {@link AcpRuntimeSupport#resolveCwd}.
|
||||
String resolvedCwd = runtimeSupport.resolveCwd(endpoint, cwdHint);
|
||||
|
||||
StringBuilder accumulator = new StringBuilder();
|
||||
AcpStdioClient client;
|
||||
try {
|
||||
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||
args, env, cwdHint);
|
||||
args, env, resolvedCwd);
|
||||
} catch (IOException e) {
|
||||
throw new MateClawException("err.acp.spawn_failed",
|
||||
"Failed to spawn ACP agent '" + endpointName + "': " + e.getMessage());
|
||||
@ -109,7 +114,7 @@ public class AcpDelegationService {
|
||||
"ACP protocol mismatch with endpoint '" + endpointName + "'");
|
||||
}
|
||||
|
||||
JsonNode session = autoClose.newSession(cwdHint, SESSION_NEW_TIMEOUT_MS);
|
||||
JsonNode session = autoClose.newSession(resolvedCwd, SESSION_NEW_TIMEOUT_MS);
|
||||
String sessionId = session == null ? null : session.path("sessionId").asText("");
|
||||
if (sessionId == null || sessionId.isBlank()) {
|
||||
throw new MateClawException("err.acp.session_failed",
|
||||
@ -123,6 +128,15 @@ public class AcpDelegationService {
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
log.warn("ACP delegation failed for endpoint '{}': {}", endpointName, e.getMessage());
|
||||
// Upstream CLIs (claude-code / codex / qwen-code) wrap their
|
||||
// own auth failures in opaque JSON-RPC noise. Recognise the
|
||||
// 401/403/forbidden/unauthorized fingerprints and rewrite
|
||||
// the message into something the user can act on, with the
|
||||
// exact env var name they need to set.
|
||||
String authHint = runtimeSupport.translateAuthError(endpoint, e.getMessage());
|
||||
if (authHint != null) {
|
||||
throw new MateClawException("err.acp.auth_failed", authHint);
|
||||
}
|
||||
throw new MateClawException("err.acp.delegation_failed",
|
||||
"ACP delegation to '" + endpointName + "' failed: " + e.getMessage());
|
||||
}
|
||||
|
||||
@ -5,7 +5,9 @@ import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.ApplicationEventPublisher;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.event.AcpEndpointChangedEvent;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.repository.AcpEndpointMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
@ -36,6 +38,7 @@ public class AcpEndpointService {
|
||||
|
||||
private final AcpEndpointMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
public List<AcpEndpointEntity> list() {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
@ -43,6 +46,17 @@ public class AcpEndpointService {
|
||||
.orderByAsc(AcpEndpointEntity::getName));
|
||||
}
|
||||
|
||||
/**
|
||||
* Subset of {@link #list()} that returns only enabled rows.
|
||||
* Used by {@code AcpSkillBridge} to enumerate virtual skill cards
|
||||
* (one per enabled endpoint).
|
||||
*/
|
||||
public List<AcpEndpointEntity> listEnabled() {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.eq(AcpEndpointEntity::getEnabled, true)
|
||||
.orderByAsc(AcpEndpointEntity::getName));
|
||||
}
|
||||
|
||||
public AcpEndpointEntity get(Long id) {
|
||||
AcpEndpointEntity ep = mapper.selectById(id);
|
||||
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
|
||||
@ -80,6 +94,7 @@ public class AcpEndpointService {
|
||||
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||
mapper.insert(input);
|
||||
log.info("Created ACP endpoint: {}", input.getName());
|
||||
publish(input, AcpEndpointChangedEvent.Type.CREATED);
|
||||
return input;
|
||||
}
|
||||
|
||||
@ -104,6 +119,7 @@ public class AcpEndpointService {
|
||||
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||
}
|
||||
mapper.updateById(existing);
|
||||
publish(existing, AcpEndpointChangedEvent.Type.UPDATED);
|
||||
return existing;
|
||||
}
|
||||
|
||||
@ -115,15 +131,29 @@ public class AcpEndpointService {
|
||||
}
|
||||
mapper.deleteById(id);
|
||||
log.info("Deleted ACP endpoint: {}", existing.getName());
|
||||
publish(existing, AcpEndpointChangedEvent.Type.DELETED);
|
||||
}
|
||||
|
||||
public AcpEndpointEntity toggle(Long id, boolean enabled) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
existing.setEnabled(enabled);
|
||||
mapper.updateById(existing);
|
||||
publish(existing, AcpEndpointChangedEvent.Type.TOGGLED);
|
||||
return existing;
|
||||
}
|
||||
|
||||
private void publish(AcpEndpointEntity ep, AcpEndpointChangedEvent.Type type) {
|
||||
try {
|
||||
eventPublisher.publishEvent(new AcpEndpointChangedEvent(
|
||||
ep.getId(), ep.getName(), type));
|
||||
} catch (Exception e) {
|
||||
// Listener failures must not break the CRUD path. The bridge
|
||||
// will resync on the next ApplicationReady tick anyway.
|
||||
log.warn("Failed to publish AcpEndpointChangedEvent for '{}': {}",
|
||||
ep.getName(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a connection-test outcome on the row. */
|
||||
public void recordTestResult(Long id, String status, String error) {
|
||||
AcpEndpointEntity existing = mapper.selectById(id);
|
||||
|
||||
@ -0,0 +1,188 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.workspace.core.model.WorkspaceEntity;
|
||||
import vip.mate.workspace.core.service.WorkspaceService;
|
||||
|
||||
import java.io.File;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* Shared runtime helpers for ACP code paths.
|
||||
*
|
||||
* <p>Two responsibilities, both motivated by upstream ACP servers
|
||||
* (e.g. {@code @zed-industries/claude-agent-acp}) being strict about
|
||||
* inputs and noisy in failure modes:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #resolveCwd} — pick a non-blank cwd for {@code session/new}.
|
||||
* Zed's ACP Zod schema marks {@code cwd} as a required string and
|
||||
* returns {@code -32602 Invalid params} when it's missing. We
|
||||
* prefer the endpoint's bound workspace {@code base_path} (per-
|
||||
* workspace context) and fall back to the JVM working directory
|
||||
* only as a last resort. Never returns null/blank.</li>
|
||||
*
|
||||
* <li>{@link #translateAuthError} — turn upstream JSON-RPC noise like
|
||||
* {@code "API Error: 403 {...forbidden...}"} into an actionable
|
||||
* hint that names the env var the user actually has to set in
|
||||
* Settings ▸ ACP Endpoints (e.g. {@code ANTHROPIC_API_KEY} for
|
||||
* claude-code, {@code OPENAI_API_KEY} for codex). Returns
|
||||
* {@code null} when the error doesn't smell like an auth failure.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AcpRuntimeSupport {
|
||||
|
||||
private final WorkspaceService workspaceService;
|
||||
|
||||
/**
|
||||
* Resolution order (first non-blank wins):
|
||||
* <ol>
|
||||
* <li>Caller-provided hint (skill manifest's {@code acp.cwd},
|
||||
* wrapper tool {@code cwd} arg, or explicit override).</li>
|
||||
* <li>Workspace {@code base_path} when the endpoint is bound to a
|
||||
* workspace and the workspace declares one.</li>
|
||||
* <li>{@code System.getProperty("user.dir")} — the JVM working
|
||||
* directory at server launch. Reasonable for a single-user
|
||||
* desktop install, but exposes the server's launch dir to the
|
||||
* upstream agent, which is why it's last.</li>
|
||||
* </ol>
|
||||
*/
|
||||
public String resolveCwd(AcpEndpointEntity endpoint, String callerHint) {
|
||||
if (callerHint != null && !callerHint.isBlank()) {
|
||||
return callerHint;
|
||||
}
|
||||
if (endpoint != null && endpoint.getWorkspaceId() != null) {
|
||||
try {
|
||||
WorkspaceEntity ws = workspaceService.getById(endpoint.getWorkspaceId());
|
||||
if (ws != null && ws.getBasePath() != null && !ws.getBasePath().isBlank()) {
|
||||
File f = new File(ws.getBasePath());
|
||||
if (f.isDirectory()) return f.getAbsolutePath();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Workspace lookup failed for ACP cwd default (id={}): {}",
|
||||
endpoint.getWorkspaceId(), e.getMessage());
|
||||
}
|
||||
}
|
||||
return System.getProperty("user.dir", ".");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect upstream auth errors and emit an actionable hint string.
|
||||
* Returns null when the message doesn't match — caller should keep
|
||||
* the original error as-is.
|
||||
*
|
||||
* <p>Heuristic: looks for HTTP-like 401/403 markers OR the words
|
||||
* {@code forbidden / unauthorized / not allowed / api key / token}
|
||||
* in the original message (case-insensitive). The patterns are loose
|
||||
* on purpose — different ACP CLIs phrase auth errors differently
|
||||
* and the cost of a false positive (a slightly more verbose error
|
||||
* banner) is much smaller than a false negative (user staring at a
|
||||
* raw JSON-RPC blob).
|
||||
*
|
||||
* <p>Special case: a claude-code endpoint returning {@code 403
|
||||
* "Request not allowed"} is almost always the keychain-hijack
|
||||
* scenario rather than a wrong API key. The third-party
|
||||
* {@code @zed-industries/claude-agent-acp} package wraps
|
||||
* {@code @anthropic-ai/claude-agent-sdk}, whose auth dispatcher
|
||||
* checks the macOS keychain ({@code Claude Code-credentials}) /
|
||||
* {@code ~/.claude/credentials.json} BEFORE the
|
||||
* {@code ANTHROPIC_API_KEY} env var. So a host that's done
|
||||
* {@code claude login} silently shadows whatever API key the user
|
||||
* configured in the endpoint env, and Anthropic's API rejects the
|
||||
* subscription OAuth token (first-party-only) with the very
|
||||
* specific {@code "Request not allowed"} error string. We detect
|
||||
* that exact combination and surface the keychain-clearing remedy
|
||||
* instead of the generic "set ANTHROPIC_API_KEY" hint, which
|
||||
* doesn't apply here.
|
||||
*/
|
||||
public String translateAuthError(AcpEndpointEntity endpoint, String originalMessage) {
|
||||
if (originalMessage == null) return null;
|
||||
String lower = originalMessage.toLowerCase(Locale.ROOT);
|
||||
boolean looksLikeAuth =
|
||||
lower.contains("403")
|
||||
|| lower.contains("401")
|
||||
|| lower.contains("forbidden")
|
||||
|| lower.contains("unauthorized")
|
||||
|| lower.contains("not allowed")
|
||||
|| lower.contains("invalid api key")
|
||||
|| lower.contains("invalid token")
|
||||
|| lower.contains("authenticate");
|
||||
if (!looksLikeAuth) return null;
|
||||
|
||||
String name = endpoint != null && endpoint.getName() != null ? endpoint.getName() : "(unknown)";
|
||||
String slug = lower(name);
|
||||
String command = endpoint != null ? lower(endpoint.getCommand()) : "";
|
||||
|
||||
// Keychain-hijack detection — must come before the generic env-
|
||||
// missing branch because both would superficially match.
|
||||
boolean keychainHijack = lower.contains("request not allowed")
|
||||
&& (slug.contains("claude") || command.contains("claude-agent-acp"));
|
||||
if (keychainHijack) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ACP endpoint '").append(name).append("' upstream auth failed with ");
|
||||
sb.append("'Request not allowed' — almost always means the host CLI's OAuth ");
|
||||
sb.append("credentials are hijacking the SDK auth path. ");
|
||||
sb.append("The Claude Agent SDK reads ~/.claude/ / macOS keychain BEFORE the ");
|
||||
sb.append("ANTHROPIC_API_KEY env var, so the API key you configured here is ");
|
||||
sb.append("never sent — Anthropic rejects the subscription OAuth token because ");
|
||||
sb.append("third-party processes aren't allowed to use it. ");
|
||||
sb.append("To fix: ");
|
||||
sb.append("(macOS) run `claude logout`, or `security delete-generic-password ");
|
||||
sb.append("-s \"Claude Code-credentials\"`; ");
|
||||
sb.append("(Linux / Windows) delete ~/.claude/credentials.json. ");
|
||||
sb.append("Then click Test connection again. Original: ").append(originalMessage);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
String envVar = expectedAuthEnvVar(endpoint);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("ACP endpoint '").append(name).append("' upstream auth failed. ");
|
||||
sb.append("Most likely the endpoint env has no API key. ");
|
||||
sb.append("Edit Settings ▸ ACP Endpoints → ").append(name).append(" → env, ");
|
||||
if (envVar != null) {
|
||||
sb.append("add `{\"").append(envVar).append("\":\"...\"}`");
|
||||
} else {
|
||||
sb.append("add the appropriate API key for this CLI");
|
||||
}
|
||||
sb.append(". Note: claude-code / codex / qwen-code refuse OAuth tokens from their host CLIs, ");
|
||||
sb.append("so a real API key is required. Original: ").append(originalMessage);
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort guess of the API key env var the upstream CLI expects.
|
||||
* Returns null when we don't recognise the endpoint — caller emits a
|
||||
* generic "appropriate API key" hint instead.
|
||||
*/
|
||||
public String expectedAuthEnvVar(AcpEndpointEntity endpoint) {
|
||||
if (endpoint == null) return null;
|
||||
String name = lower(endpoint.getName());
|
||||
String command = lower(endpoint.getCommand());
|
||||
// Match by name first (slug is the stable identifier); fall back
|
||||
// to command keywords for user-defined rows.
|
||||
if (name.contains("claude") || command.contains("claude-agent-acp") || command.contains("anthropic")) {
|
||||
return "ANTHROPIC_API_KEY";
|
||||
}
|
||||
if (name.contains("codex") || command.contains("codex") || command.contains("openai")) {
|
||||
return "OPENAI_API_KEY";
|
||||
}
|
||||
if (name.contains("qwen") || command.contains("qwen") || command.contains("dashscope")) {
|
||||
return "DASHSCOPE_API_KEY";
|
||||
}
|
||||
if (name.contains("gemini") || command.contains("gemini") || command.contains("google-genai")) {
|
||||
return "GOOGLE_API_KEY";
|
||||
}
|
||||
// opencode multi-model — no single canonical env var.
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String lower(String s) {
|
||||
return s == null ? "" : s.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
}
|
||||
@ -228,25 +228,74 @@ public class AgentBindingService {
|
||||
* {@link #getEffectiveToolNames} allowlist completely.
|
||||
*/
|
||||
private static final Set<String> SYSTEM_LEVEL_TOOLS = Set.of(
|
||||
// Memory write/read primitives — every agent needs these
|
||||
// regardless of skill bindings, otherwise the self-evolution
|
||||
// path collapses (§11.3 / §11.4).
|
||||
// Structured memory primitives — used by every agent regardless
|
||||
// of skill bindings, otherwise the self-evolution path collapses
|
||||
// (§11.3 / §11.4).
|
||||
"record_lesson",
|
||||
"remember",
|
||||
"remember_structured",
|
||||
"recall_structured",
|
||||
"forget_structured",
|
||||
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md)
|
||||
"read_workspace_file",
|
||||
"write_workspace_file",
|
||||
"list_workspace_files",
|
||||
// Workspace memory file CRUD (PROFILE.md / MEMORY.md / SOUL.md /
|
||||
// memory/YYYY-MM-DD.md). Prior versions whitelisted
|
||||
// "read_workspace_file" / "write_workspace_file" /
|
||||
// "list_workspace_files" — those names match no @Tool bean; the
|
||||
// actual function names carry the "_memory" segment, so the
|
||||
// earlier carve-out was silently dead.
|
||||
"list_workspace_memory_files",
|
||||
"read_workspace_memory_file",
|
||||
"write_workspace_memory_file",
|
||||
"edit_workspace_memory_file",
|
||||
// Skill discovery / dispatch — skills are docs, not callables;
|
||||
// these helpers let the LLM read SKILL.md / run scripts.
|
||||
"readSkillFile",
|
||||
"runSkillScript",
|
||||
// Date/time + delegate — fundamental cross-skill utilities
|
||||
"datetime",
|
||||
"delegate_agent"
|
||||
"listSkillFiles",
|
||||
"listAvailableSkills",
|
||||
// Date / time — prior whitelist had a fictional "datetime"; the
|
||||
// real DateTimeTool exposes three separate methods.
|
||||
"getCurrentDate",
|
||||
"getCurrentDateTime",
|
||||
"getCurrentTime",
|
||||
// Multi-agent delegation — prior whitelist had "delegate_agent",
|
||||
// but DelegateAgentTool's @Tool methods are delegateToAgent /
|
||||
// delegateParallel / listAvailableAgents. Same dead-name bug.
|
||||
"delegateToAgent",
|
||||
"delegateParallel",
|
||||
"listAvailableAgents",
|
||||
// Document / media generation — agent-wide capabilities, never
|
||||
// declared inside any skill manifest. Pre-Phase-2b these were
|
||||
// universally visible; the new gate silently strips them whenever
|
||||
// any skill is bound, breaking "generate a Word doc / image /
|
||||
// song / video" intents on agents that happen to have a skill
|
||||
// on. Regression observed 2026-05-01: a Code Reviewer agent with
|
||||
// skills bound dropped renderDocx and fell back to dumping the
|
||||
// markdown body for the user to copy.
|
||||
"renderDocx",
|
||||
"renderDocxFromFile",
|
||||
"renderDocxFromFiles",
|
||||
"image_generate",
|
||||
"music_generate",
|
||||
"video_generate",
|
||||
// Universal capabilities the global system prompts (SOUL.md /
|
||||
// AGENTS.md / "Web Search Capability" / "File Reading Guidelines")
|
||||
// explicitly tell the LLM exist. Pre-Phase-2b they were globally
|
||||
// available; the new gate silently hid them on any agent with
|
||||
// skills bound, so the prompt promises a tool the registry then
|
||||
// refuses ("Tool not found: search"). Observed 2026-05-01 on the
|
||||
// Code Reviewer agent — the model called search → got
|
||||
// not-found → gave up before ever reaching renderDocx.
|
||||
"search",
|
||||
"browser_use",
|
||||
"read_file",
|
||||
"write_file",
|
||||
"edit_file",
|
||||
"execute_shell_command",
|
||||
"detect_file_type",
|
||||
"extract_document_text",
|
||||
"extract_pdf_text",
|
||||
"extract_docx_text",
|
||||
"readMateClawDoc"
|
||||
);
|
||||
|
||||
private ResolvedSkill findResolvedSkillById(Long skillId) {
|
||||
|
||||
@ -0,0 +1,495 @@
|
||||
package vip.mate.skill.acp;
|
||||
|
||||
import cn.hutool.json.JSONObject;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.event.AcpEndpointChangedEvent;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.service.AcpDelegationService;
|
||||
import vip.mate.acp.service.AcpEndpointService;
|
||||
import vip.mate.skill.knowledge.SkillScopedToolCallback;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.model.SkillEntity;
|
||||
import vip.mate.skill.runtime.model.ResolvedSkill;
|
||||
import vip.mate.tool.ToolRegistry;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* RFC-090 §3.2 / §14.4 (parallel) — ACP endpoint → virtual skill bridge.
|
||||
*
|
||||
* <p>Mirrors {@link vip.mate.skill.mcp.McpSkillBridge}: every enabled
|
||||
* row in {@code mate_acp_endpoint} is automatically surfaced as a
|
||||
* virtual {@link SkillEntity} + {@link ResolvedSkill}, and a wrapper
|
||||
* tool ({@code acp_<slug>_prompt}) is registered with
|
||||
* {@link ToolRegistry} so any agent can delegate to that endpoint
|
||||
* without manual binding.
|
||||
*
|
||||
* <p>This solves the "ACP configured as skill cannot be called"
|
||||
* usability bug (matches the inspiration from QwenPaw's
|
||||
* {@code delegate_external_agent} pattern, but keeps MateClaw's
|
||||
* skill-card affordance for endpoint discovery): the user manages
|
||||
* endpoints in Settings ▸ ACP Endpoints, and a card automatically
|
||||
* appears on the Skills page — no per-endpoint SKILL.md authoring
|
||||
* required.
|
||||
*
|
||||
* <p>Lifecycle:
|
||||
* <ul>
|
||||
* <li>{@link ApplicationReadyEvent} — initial wrapper registration
|
||||
* for all enabled endpoints.</li>
|
||||
* <li>{@link AcpEndpointChangedEvent} — re-sync registrations on
|
||||
* create / update / toggle / delete.</li>
|
||||
* <li>{@code list/list-status} entry points rebuild the virtual
|
||||
* SkillEntity / ResolvedSkill snapshots on demand so the Skills
|
||||
* page always shows current state.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>ID namespace: virtual skill ids use a high sentinel
|
||||
* {@link #VIRTUAL_ID_BASE} different from the MCP bridge's, so the two
|
||||
* id spaces never collide and a callsite can dispatch on which bridge
|
||||
* owns an id without coordination.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
public class AcpSkillBridge {
|
||||
|
||||
/**
|
||||
* High sentinel for ACP virtual id space. Distinct from
|
||||
* {@code McpSkillBridge.VIRTUAL_ID_BASE} (9e18) so the two virtual
|
||||
* spaces are partitionable by simple range checks.
|
||||
*/
|
||||
public static final long VIRTUAL_ID_BASE = 8_000_000_000_000_000_000L;
|
||||
/** Upper bound, exclusive — anything in [BASE, BASE + 1e17) is ours. */
|
||||
public static final long VIRTUAL_ID_BOUND = VIRTUAL_ID_BASE + 100_000_000_000_000_000L;
|
||||
|
||||
private final AcpEndpointService endpointService;
|
||||
private final AcpDelegationService delegationService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ToolRegistry toolRegistry;
|
||||
|
||||
/**
|
||||
* endpointId → set of registered wrapper tool names. Keeps the tool
|
||||
* registry synced with the live endpoint set: when an endpoint is
|
||||
* toggled off/deleted, we know exactly which tools to remove.
|
||||
*/
|
||||
private final ConcurrentHashMap<Long, Set<String>> registeredWrappers = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
public AcpSkillBridge(AcpEndpointService endpointService,
|
||||
AcpDelegationService delegationService,
|
||||
ObjectMapper objectMapper,
|
||||
@Lazy ToolRegistry toolRegistry) {
|
||||
this.endpointService = endpointService;
|
||||
this.delegationService = delegationService;
|
||||
this.objectMapper = objectMapper;
|
||||
this.toolRegistry = toolRegistry;
|
||||
}
|
||||
|
||||
public static boolean isVirtualAcpSkillId(Long id) {
|
||||
return id != null && id >= VIRTUAL_ID_BASE && id < VIRTUAL_ID_BOUND;
|
||||
}
|
||||
|
||||
public static Long extractEndpointId(Long virtualId) {
|
||||
if (!isVirtualAcpSkillId(virtualId)) return null;
|
||||
return virtualId - VIRTUAL_ID_BASE;
|
||||
}
|
||||
|
||||
public static long virtualIdFor(AcpEndpointEntity endpoint) {
|
||||
return VIRTUAL_ID_BASE + endpoint.getId();
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
log.info("AcpSkillBridge initialized (virtual ID range: {}+endpointId)", VIRTUAL_ID_BASE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial registration: enumerate enabled endpoints once the
|
||||
* application is fully bootstrapped. {@link ApplicationReadyEvent}
|
||||
* is preferred over {@code @PostConstruct} because the database
|
||||
* bootstrap (Flyway + seed data) finishes only after the context
|
||||
* comes up, and the endpoint table may be empty before that.
|
||||
*/
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onReady() {
|
||||
try {
|
||||
int registered = 0;
|
||||
for (AcpEndpointEntity ep : endpointService.listEnabled()) {
|
||||
registerWrappers(ep);
|
||||
registered++;
|
||||
}
|
||||
if (registered > 0) {
|
||||
log.info("AcpSkillBridge: registered wrappers for {} enabled endpoint(s)", registered);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AcpSkillBridge initial registration failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resync wrapper registrations on every endpoint lifecycle event.
|
||||
* Disabled / deleted endpoints have their wrappers removed; enabled
|
||||
* endpoints are re-registered (idempotent — we deregister-then-add
|
||||
* to avoid double registration when the user re-enables a row).
|
||||
*/
|
||||
@EventListener(AcpEndpointChangedEvent.class)
|
||||
public void onEndpointChanged(AcpEndpointChangedEvent event) {
|
||||
Long endpointId = event.endpointId();
|
||||
if (endpointId == null) return;
|
||||
try {
|
||||
switch (event.type()) {
|
||||
case DELETED -> deregisterWrappers(endpointId);
|
||||
case CREATED, UPDATED, TOGGLED -> {
|
||||
AcpEndpointEntity ep = safeGet(endpointId);
|
||||
if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) {
|
||||
deregisterWrappers(endpointId);
|
||||
} else {
|
||||
// Idempotent: drop stale set, then build fresh —
|
||||
// covers args/env edits where the wrapper itself
|
||||
// doesn't change but we still want a clean slate.
|
||||
deregisterWrappers(endpointId);
|
||||
registerWrappers(ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AcpSkillBridge resync for endpoint '{}' ({}) failed: {}",
|
||||
event.name(), event.type(), e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every enabled endpoint as a virtual {@link SkillEntity}.
|
||||
* Used by the Skills list endpoint; rows are non-persistent and
|
||||
* regenerated on each call.
|
||||
*/
|
||||
public List<SkillEntity> listAcpDerivedSkillEntities() {
|
||||
return safeListEnabled().stream().map(this::endpointToEntity).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot every enabled endpoint as a virtual {@link ResolvedSkill}
|
||||
* with synthesized manifest. Status reflects the last connection
|
||||
* test on the row: OK → READY, ERROR / unknown → SETUP_NEEDED.
|
||||
*/
|
||||
public List<ResolvedSkill> listAcpDerivedResolvedSkills() {
|
||||
return safeListEnabled().stream().map(this::endpointToResolved).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Lookup a single virtual ResolvedSkill by virtual id. Used by the
|
||||
* Skill detail drawer's reverse lookup path.
|
||||
*/
|
||||
public ResolvedSkill findResolvedById(Long virtualId) {
|
||||
Long endpointId = extractEndpointId(virtualId);
|
||||
if (endpointId == null) return null;
|
||||
AcpEndpointEntity ep = safeGet(endpointId);
|
||||
return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToResolved(ep) : null;
|
||||
}
|
||||
|
||||
public SkillEntity findEntityById(Long virtualId) {
|
||||
Long endpointId = extractEndpointId(virtualId);
|
||||
if (endpointId == null) return null;
|
||||
AcpEndpointEntity ep = safeGet(endpointId);
|
||||
return ep != null && Boolean.TRUE.equals(ep.getEnabled()) ? endpointToEntity(ep) : null;
|
||||
}
|
||||
|
||||
// ==================== Tool registration ====================
|
||||
|
||||
private void registerWrappers(AcpEndpointEntity ep) {
|
||||
if (ep == null || !Boolean.TRUE.equals(ep.getEnabled())) return;
|
||||
String slug = slugify(ep.getName());
|
||||
if (slug.isEmpty()) {
|
||||
log.warn("ACP endpoint id={} has blank name; cannot register wrapper", ep.getId());
|
||||
return;
|
||||
}
|
||||
String toolName = "acp_" + slug + "_prompt";
|
||||
String desc = String.format(
|
||||
"Delegate a prompt to the '%s' ACP coding agent. " +
|
||||
"Send a single-string instruction; receive the agent's final reply.%s",
|
||||
ep.getName(),
|
||||
ep.getDescription() == null || ep.getDescription().isBlank()
|
||||
? ""
|
||||
: " — " + ep.getDescription());
|
||||
// cwd stays optional in the schema so the LLM doesn't have to
|
||||
// invent a path. The server defaults to the endpoint's bound
|
||||
// workspace base_path when omitted (see AcpRuntimeSupport).
|
||||
String schema = "{"
|
||||
+ "\"type\":\"object\","
|
||||
+ "\"properties\":{"
|
||||
+ "\"prompt\":{\"type\":\"string\",\"description\":\"the instruction or question to send\"},"
|
||||
+ "\"cwd\":{\"type\":\"string\",\"description\":\"optional working directory; "
|
||||
+ "defaults to the endpoint's workspace base path when omitted\"}"
|
||||
+ "},"
|
||||
+ "\"required\":[\"prompt\"]"
|
||||
+ "}";
|
||||
|
||||
final String endpointName = ep.getName();
|
||||
final Long endpointId = ep.getId();
|
||||
|
||||
SkillScopedToolCallback callback = new SkillScopedToolCallback(toolName, desc, schema, input -> {
|
||||
try {
|
||||
JsonNode args = input == null || input.isBlank()
|
||||
? objectMapper.createObjectNode()
|
||||
: objectMapper.readTree(input);
|
||||
String userPrompt = args.path("prompt").asText("").trim();
|
||||
if (userPrompt.isEmpty()) return errorJson("prompt is required");
|
||||
String cwdHint = args.path("cwd").asText("");
|
||||
String reply = delegationService.prompt(endpointName, userPrompt,
|
||||
cwdHint == null || cwdHint.isBlank() ? null : cwdHint);
|
||||
JSONObject resp = new JSONObject()
|
||||
.set("endpoint", endpointName)
|
||||
.set("reply", reply);
|
||||
return JSONUtil.toJsonStr(resp);
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP wrapper '{}' failed: {}", toolName, e.getMessage());
|
||||
return errorJson(e.getMessage() == null ? "delegation failed" : e.getMessage());
|
||||
}
|
||||
});
|
||||
|
||||
// Availability supplier: re-check each agent tool-set build so
|
||||
// a toggle-off without a deregister call still hides the tool.
|
||||
toolRegistry.registerPluginTool(callback, () -> {
|
||||
AcpEndpointEntity live = safeGet(endpointId);
|
||||
return live != null && Boolean.TRUE.equals(live.getEnabled());
|
||||
});
|
||||
registeredWrappers.computeIfAbsent(endpointId, k -> ConcurrentHashMap.newKeySet()).add(toolName);
|
||||
log.info("AcpSkillBridge: registered wrapper '{}' for endpoint '{}'", toolName, endpointName);
|
||||
}
|
||||
|
||||
private void deregisterWrappers(Long endpointId) {
|
||||
Set<String> names = registeredWrappers.remove(endpointId);
|
||||
if (names == null || names.isEmpty()) return;
|
||||
for (String name : names) {
|
||||
try {
|
||||
toolRegistry.unregisterPluginTool(name);
|
||||
} catch (Exception e) {
|
||||
log.debug("Unregister ACP wrapper '{}' failed: {}", name, e.getMessage());
|
||||
}
|
||||
}
|
||||
log.info("AcpSkillBridge: deregistered {} wrapper(s) for endpoint id={}", names.size(), endpointId);
|
||||
}
|
||||
|
||||
// ==================== Synthesis ====================
|
||||
|
||||
private SkillEntity endpointToEntity(AcpEndpointEntity ep) {
|
||||
SkillEntity s = new SkillEntity();
|
||||
s.setId(virtualIdFor(ep));
|
||||
s.setName(slugify(ep.getName()));
|
||||
s.setNameEn(displayName(ep));
|
||||
s.setNameZh(ep.getDescription() != null && !ep.getDescription().isBlank()
|
||||
? displayName(ep) : null);
|
||||
s.setDescription(buildDescription(ep));
|
||||
s.setSkillType("acp");
|
||||
s.setIcon(iconFor(ep));
|
||||
s.setVersion("1.0.0");
|
||||
s.setAuthor("acp-bridge");
|
||||
s.setEnabled(Boolean.TRUE.equals(ep.getEnabled()));
|
||||
s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin()));
|
||||
s.setTags("acp");
|
||||
s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts
|
||||
s.setConfigJson(buildConfigJson(ep));
|
||||
s.setManifestJson(serializeManifest(buildManifest(ep)));
|
||||
return s;
|
||||
}
|
||||
|
||||
private ResolvedSkill endpointToResolved(AcpEndpointEntity ep) {
|
||||
SkillManifest manifest = buildManifest(ep);
|
||||
boolean ok = "OK".equalsIgnoreCase(nullSafe(ep.getLastStatus()));
|
||||
boolean errored = "ERROR".equalsIgnoreCase(nullSafe(ep.getLastStatus()))
|
||||
|| (ep.getLastError() != null && !ep.getLastError().isBlank());
|
||||
// "Unknown" (untested) endpoints are treated as READY so the user
|
||||
// can call them without an explicit Test click — unlike MCP, an
|
||||
// ACP CLI is spawned per call so an untested-but-installed CLI
|
||||
// works fine on first invocation. ERROR keeps SETUP_NEEDED.
|
||||
boolean ready = !errored;
|
||||
|
||||
Map<String, String> featureStatuses = new LinkedHashMap<>();
|
||||
featureStatuses.put("default", ready ? "READY" : "SETUP_NEEDED");
|
||||
Set<String> active = new LinkedHashSet<>();
|
||||
if (ready) active.add("default");
|
||||
|
||||
List<String> missing = new ArrayList<>();
|
||||
if (!ready) {
|
||||
missing.add("acp:" + ep.getName() + " (status: " + nullSafe(ep.getLastStatus()) + ")");
|
||||
}
|
||||
|
||||
String summary = ok
|
||||
? "ACP endpoint '" + ep.getName() + "' tested OK"
|
||||
: (errored
|
||||
? "ACP endpoint '" + ep.getName() + "' last test failed: " + nullSafe(ep.getLastError())
|
||||
: "ACP endpoint '" + ep.getName() + "' not yet tested — calls will spawn the CLI on demand");
|
||||
|
||||
return ResolvedSkill.builder()
|
||||
.id(virtualIdFor(ep))
|
||||
.name(slugify(ep.getName()))
|
||||
.description(buildDescription(ep))
|
||||
.content("") // no SKILL.md
|
||||
.source("acp")
|
||||
.skillDir(null)
|
||||
.configuredSkillDir(null)
|
||||
.runtimeAvailable(ready)
|
||||
.resolutionError(ready ? null : nullSafe(ep.getLastError()))
|
||||
.references(Map.of())
|
||||
.scripts(Map.of())
|
||||
.enabled(Boolean.TRUE.equals(ep.getEnabled()))
|
||||
.icon(iconFor(ep))
|
||||
.builtin(Boolean.TRUE.equals(ep.getBuiltin()))
|
||||
.securityBlocked(false)
|
||||
.securitySummary("ACP-derived skill (external CLI; not subject to SKILL.md scanning)")
|
||||
.dependencyReady(ready)
|
||||
.missingDependencies(missing)
|
||||
.dependencySummary(summary)
|
||||
.manifest(manifest)
|
||||
.featureStatuses(featureStatuses)
|
||||
.activeFeatures(active)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synthesize a §10.2 minimal manifest from the live endpoint row.
|
||||
* The wrapper tool name {@code acp_<slug>_prompt} is the single
|
||||
* advertised tool, surfaced via {@code allowedTools} + the default
|
||||
* feature so {@code ResolvedSkill.getEffectiveAllowedTools()} picks
|
||||
* it up the same way as a hand-authored skill manifest.
|
||||
*/
|
||||
private SkillManifest buildManifest(AcpEndpointEntity ep) {
|
||||
String slug = slugify(ep.getName());
|
||||
String toolName = "acp_" + slug + "_prompt";
|
||||
List<String> tools = List.of(toolName);
|
||||
|
||||
SkillManifest.FeatureDef defaultFeature = SkillManifest.FeatureDef.builder()
|
||||
.id("default")
|
||||
.label(displayName(ep))
|
||||
.requires(List.of("acp:" + ep.getName()))
|
||||
.platforms(List.of())
|
||||
.tools(tools)
|
||||
.build();
|
||||
|
||||
SkillManifest.RequirementDef acpRequirement = SkillManifest.RequirementDef.builder()
|
||||
.key("acp:" + ep.getName())
|
||||
.type("acp")
|
||||
.check(ep.getName())
|
||||
.description("ACP endpoint '" + ep.getName() + "' must be enabled and reachable. "
|
||||
+ "Configure in Settings ▸ ACP Endpoints.")
|
||||
.build();
|
||||
|
||||
SkillManifest.AcpBinding binding = SkillManifest.AcpBinding.builder()
|
||||
.endpoint(ep.getName())
|
||||
.resolvedEndpointId(ep.getId())
|
||||
.build();
|
||||
|
||||
return SkillManifest.builder()
|
||||
.id(slug)
|
||||
.name(slug)
|
||||
.description(buildDescription(ep))
|
||||
.icon(iconFor(ep))
|
||||
.version("1.0.0")
|
||||
.author("acp-bridge")
|
||||
.type("acp")
|
||||
.category("system")
|
||||
.allowedTools(tools)
|
||||
.requires(List.of(acpRequirement))
|
||||
.features(List.of(defaultFeature))
|
||||
.acp(binding)
|
||||
.selfEvolution(SkillManifest.SelfEvolution.builder()
|
||||
// Bridged ACP cards don't author LESSONS.md — the
|
||||
// upstream agent owns its own self-evolution.
|
||||
.lessonsEnabled(false)
|
||||
.lessonsMaxEntries(0)
|
||||
.memoryWritesAllowed(true)
|
||||
.build())
|
||||
.extras(Map.of("acpEndpointId", ep.getId()))
|
||||
.build();
|
||||
}
|
||||
|
||||
// ==================== Helpers ====================
|
||||
|
||||
private List<AcpEndpointEntity> safeListEnabled() {
|
||||
try {
|
||||
return endpointService.listEnabled();
|
||||
} catch (Exception e) {
|
||||
log.warn("AcpSkillBridge could not list enabled endpoints: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
private AcpEndpointEntity safeGet(Long id) {
|
||||
try {
|
||||
return endpointService.get(id);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String slugify(String raw) {
|
||||
if (raw == null) return "";
|
||||
return raw.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9_-]", "-");
|
||||
}
|
||||
|
||||
private String displayName(AcpEndpointEntity ep) {
|
||||
if (ep.getDisplayName() != null && !ep.getDisplayName().isBlank()) return ep.getDisplayName();
|
||||
return ep.getName() != null ? ep.getName() : "acp-" + ep.getId();
|
||||
}
|
||||
|
||||
private String buildDescription(AcpEndpointEntity ep) {
|
||||
if (ep.getDescription() != null && !ep.getDescription().isBlank()) return ep.getDescription();
|
||||
return "ACP coding agent '" + ep.getName() + "' — delegate prompts via the "
|
||||
+ "auto-registered tool. Configure in Settings ▸ ACP Endpoints.";
|
||||
}
|
||||
|
||||
private String iconFor(AcpEndpointEntity ep) {
|
||||
// Light heuristic — match the most popular ACP runners by name.
|
||||
String n = nullSafe(ep.getName()).toLowerCase(Locale.ROOT);
|
||||
if (n.contains("claude")) return "🟠";
|
||||
if (n.contains("codex") || n.contains("openai")) return "⚪";
|
||||
if (n.contains("qwen")) return "🔵";
|
||||
if (n.contains("gemini") || n.contains("google")) return "🟡";
|
||||
if (n.contains("opencode")) return "🟢";
|
||||
return "🤝";
|
||||
}
|
||||
|
||||
private String buildConfigJson(AcpEndpointEntity ep) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(Map.of(
|
||||
"acpEndpointId", ep.getId(),
|
||||
"command", nullSafe(ep.getCommand()),
|
||||
"trusted", Boolean.TRUE.equals(ep.getTrusted()),
|
||||
"source", Map.of("type", "acp")));
|
||||
} catch (Exception e) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private String serializeManifest(SkillManifest manifest) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(manifest);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String nullSafe(String s) {
|
||||
return s == null ? "" : s;
|
||||
}
|
||||
|
||||
private static String errorJson(String msg) {
|
||||
return JSONUtil.createObj().set("error", msg).toString();
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,7 @@ public class SkillController {
|
||||
private final AgentService agentService;
|
||||
private final AgentBindingService agentBindingService;
|
||||
private final vip.mate.skill.mcp.McpSkillBridge mcpSkillBridge;
|
||||
private final vip.mate.skill.acp.AcpSkillBridge acpSkillBridge;
|
||||
|
||||
@Operation(summary = "获取技能分页列表(RFC-042 §2.1)")
|
||||
@GetMapping
|
||||
@ -93,6 +94,29 @@ public class SkillController {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
}
|
||||
// RFC-090 §3.2 (parallel) — same auto-bridge for ACP endpoints.
|
||||
if (page == 1 && (skillType == null || skillType.isBlank() || "acp".equalsIgnoreCase(skillType))) {
|
||||
try {
|
||||
List<SkillEntity> acpSkills = acpSkillBridge.listAcpDerivedSkillEntities();
|
||||
if (!acpSkills.isEmpty()) {
|
||||
String kw = keyword == null ? "" : keyword.trim().toLowerCase();
|
||||
List<SkillEntity> filtered = acpSkills.stream()
|
||||
.filter(s -> kw.isEmpty()
|
||||
|| (s.getName() != null && s.getName().toLowerCase().contains(kw))
|
||||
|| (s.getDescription() != null && s.getDescription().toLowerCase().contains(kw)))
|
||||
.filter(s -> enabled == null || enabled.equals(s.getEnabled()))
|
||||
.toList();
|
||||
if (!filtered.isEmpty()) {
|
||||
java.util.List<SkillEntity> merged = new java.util.ArrayList<>(filtered);
|
||||
merged.addAll(dbPage.getRecords());
|
||||
dbPage.setRecords(merged);
|
||||
dbPage.setTotal(dbPage.getTotal() + filtered.size());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// Bridge failure must not break the Skills page.
|
||||
}
|
||||
}
|
||||
return R.ok(dbPage);
|
||||
}
|
||||
|
||||
@ -112,6 +136,15 @@ public class SkillController {
|
||||
} catch (Exception ignored) {
|
||||
// Bridge failure must not break the badge fetch.
|
||||
}
|
||||
try {
|
||||
long virtualAcp = acpSkillBridge.listAcpDerivedSkillEntities().size();
|
||||
if (virtualAcp > 0) {
|
||||
result.merge("acp", virtualAcp, Long::sum);
|
||||
result.merge("all", virtualAcp, Long::sum);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Same defensive stance as the MCP bridge above.
|
||||
}
|
||||
return R.ok(result);
|
||||
}
|
||||
|
||||
@ -152,6 +185,11 @@ public class SkillController {
|
||||
.map(R::ok)
|
||||
.orElse(R.fail("MCP-derived skill not found: " + id));
|
||||
}
|
||||
// RFC-090 §3.2 (parallel) — same path for ACP-derived virtual skills.
|
||||
if (vip.mate.skill.acp.AcpSkillBridge.isVirtualAcpSkillId(id)) {
|
||||
SkillEntity ent = acpSkillBridge.findEntityById(id);
|
||||
return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id);
|
||||
}
|
||||
return R.ok(skillService.getSkill(id));
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.skill.acp.AcpSkillBridge;
|
||||
import vip.mate.skill.lessons.SkillLessonsService;
|
||||
import vip.mate.skill.manifest.SkillManifest;
|
||||
import vip.mate.skill.mcp.McpSkillBridge;
|
||||
@ -47,16 +48,23 @@ public class SkillRuntimeService {
|
||||
* which boots later in the lifecycle.
|
||||
*/
|
||||
private final McpSkillBridge mcpSkillBridge;
|
||||
/**
|
||||
* RFC-090 §3.2 (parallel) — ACP-endpoint → virtual-skill bridge.
|
||||
* Same {@code @Lazy} treatment as the MCP bridge.
|
||||
*/
|
||||
private final AcpSkillBridge acpSkillBridge;
|
||||
|
||||
@Autowired
|
||||
public SkillRuntimeService(SkillService skillService,
|
||||
SkillPackageResolver packageResolver,
|
||||
@Lazy SkillLessonsService lessonsService,
|
||||
@Lazy McpSkillBridge mcpSkillBridge) {
|
||||
@Lazy McpSkillBridge mcpSkillBridge,
|
||||
@Lazy AcpSkillBridge acpSkillBridge) {
|
||||
this.skillService = skillService;
|
||||
this.packageResolver = packageResolver;
|
||||
this.lessonsService = lessonsService;
|
||||
this.mcpSkillBridge = mcpSkillBridge;
|
||||
this.acpSkillBridge = acpSkillBridge;
|
||||
}
|
||||
|
||||
// 缓存已解析的 active skills(5分钟过期)
|
||||
@ -144,6 +152,14 @@ public class SkillRuntimeService {
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP skill bridge active merge failed: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
// RFC-090 §3.2 (parallel) — ACP-derived virtual skills.
|
||||
for (ResolvedSkill virt : acpSkillBridge.listAcpDerivedResolvedSkills()) {
|
||||
if (passesActiveGate(virt)) resolved.add(virt);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP skill bridge active merge failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
activeSkillsCache.put(CACHE_KEY, resolved);
|
||||
log.info("Refreshed active skills: {} enabled", resolved.size());
|
||||
@ -169,6 +185,11 @@ public class SkillRuntimeService {
|
||||
} catch (Exception e) {
|
||||
log.warn("MCP skill bridge merge failed: {}", e.getMessage());
|
||||
}
|
||||
try {
|
||||
resolved.addAll(acpSkillBridge.listAcpDerivedResolvedSkills());
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP skill bridge merge failed: {}", e.getMessage());
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
|
||||
@ -258,9 +258,18 @@ const customRenderer = {
|
||||
// Dangerous scheme — render the inner content as plain content (no anchor).
|
||||
return innerHtml
|
||||
}
|
||||
// Defense against LLMs hallucinating a host on tool-returned download URLs.
|
||||
// /api/v1/files/generated/<id> is always same-origin; multiple models have
|
||||
// been observed prepending bogus schemes/hosts (https://localhost:8080,
|
||||
// https://ai-tools-system.com, …) when echoing the URL back, breaking the
|
||||
// download. Strip any prepended scheme://host so the link works regardless
|
||||
// of what the model wrote.
|
||||
const hostStripped = /^https?:\/\/[^/]+(\/api\/v1\/files\/generated\/.+)$/i.exec(href)
|
||||
const safeHref = hostStripped ? hostStripped[1] : href
|
||||
|
||||
let extra = ''
|
||||
try {
|
||||
const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
|
||||
const url = new URL(safeHref, typeof window !== 'undefined' ? window.location.href : 'http://localhost/')
|
||||
if (typeof window !== 'undefined' && url.origin !== window.location.origin) {
|
||||
extra = ' target="_blank" rel="noopener noreferrer"'
|
||||
}
|
||||
@ -268,7 +277,7 @@ const customRenderer = {
|
||||
// Malformed URL — treat as same-origin (relative link path).
|
||||
}
|
||||
const titleAttr = title ? ` title="${escapeHtml(title)}"` : ''
|
||||
return `<a href="${escapeHtml(href)}"${titleAttr}${extra}>${innerHtml}</a>`
|
||||
return `<a href="${escapeHtml(safeHref)}"${titleAttr}${extra}>${innerHtml}</a>`
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -1149,10 +1149,28 @@ export default {
|
||||
description: 'Description',
|
||||
command: 'Command',
|
||||
args: 'Args (JSON array)',
|
||||
env: 'Env (JSON object)',
|
||||
env: 'Environment variables',
|
||||
toolParseMode: 'Tool parse mode',
|
||||
enabled: 'Enabled',
|
||||
},
|
||||
env: {
|
||||
formMode: 'Edit as JSON',
|
||||
jsonMode: 'Edit as form',
|
||||
suggested: 'Suggested',
|
||||
addRow: 'Add variable',
|
||||
keyPlaceholder: 'KEY_NAME',
|
||||
valuePlaceholder: 'value (e.g. sk-…)',
|
||||
show: 'Show',
|
||||
hide: 'Hide',
|
||||
remove: 'Remove',
|
||||
hints: {
|
||||
anthropic: 'Anthropic API key (sk-ant-…). Get one at https://console.anthropic.com/settings/keys',
|
||||
openai: 'OpenAI API key (sk-…). Get one at https://platform.openai.com/api-keys',
|
||||
dashscope: 'Aliyun DashScope key — same value as MateClaw .env DASHSCOPE_API_KEY',
|
||||
google: 'Google AI Studio API key. Get one at https://aistudio.google.com/apikey',
|
||||
claudeOauth: 'Heads up: claude-code OAuth login (the one stored in ~/.claude/) does NOT work here, and worse — the Claude Agent SDK reads it BEFORE this env var, silently shadowing your API key. If you have ever run `claude login` on this host, clear the keychain first: (macOS) `claude logout` or `security delete-generic-password -s "Claude Code-credentials"`; (Linux/Windows) delete ~/.claude/credentials.json. Then put a real API key (sk-ant-…) above. Or switch to qwen-code / opencode to reuse a CLI login.',
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
newTitle: 'Add ACP Endpoint',
|
||||
editTitle: 'Edit ACP Endpoint',
|
||||
@ -2000,6 +2018,7 @@ export default {
|
||||
all: 'All',
|
||||
builtin: 'Built-in',
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: 'Dynamic',
|
||||
},
|
||||
search: {
|
||||
@ -2073,6 +2092,7 @@ export default {
|
||||
types: {
|
||||
builtin: 'Built-in',
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: 'Dynamic',
|
||||
},
|
||||
source: {
|
||||
|
||||
@ -277,7 +277,7 @@ export default {
|
||||
mcpConnections: 'MCP 连接',
|
||||
toolsCatalog: '工具目录',
|
||||
activity: '活动记录',
|
||||
acpEndpoints: 'ACP Endpoints',
|
||||
acpEndpoints: 'ACP 端点',
|
||||
settingsGroup: '设置',
|
||||
agents: '智能体',
|
||||
security: '安全',
|
||||
@ -1121,23 +1121,23 @@ export default {
|
||||
},
|
||||
acp: {
|
||||
kicker: '外部 Agent',
|
||||
title: 'ACP Endpoints',
|
||||
title: 'ACP 端点',
|
||||
desc: '通过 stdio 委派给外部编码 Agent(codex / claude-code / opencode / qwen-code)。安装对应 CLI 后开启该入口。',
|
||||
addEndpoint: '新增 Endpoint',
|
||||
addEndpoint: '新增端点',
|
||||
builtin: '内置',
|
||||
test: '测试',
|
||||
testing: '测试中…',
|
||||
statusUnknown: '未测试',
|
||||
empty: '暂无 ACP endpoint。',
|
||||
loadFailed: '加载 ACP endpoint 列表失败',
|
||||
saveFailed: '保存 endpoint 失败',
|
||||
empty: '暂无 ACP 端点。',
|
||||
loadFailed: '加载 ACP 端点列表失败',
|
||||
saveFailed: '保存端点失败',
|
||||
deleteTitle: '确认删除',
|
||||
deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。',
|
||||
deleteFailed: '删除 endpoint 失败',
|
||||
toggleFailed: '切换 endpoint 状态失败',
|
||||
deleteConfirm: '确认删除 ACP 端点 "{name}"?内置端点不可删除。',
|
||||
deleteFailed: '删除端点失败',
|
||||
toggleFailed: '切换端点状态失败',
|
||||
invalidJson: 'JSON 格式错误',
|
||||
columns: {
|
||||
name: 'Endpoint',
|
||||
name: '端点',
|
||||
command: '命令',
|
||||
status: '最近一次测试',
|
||||
enabled: '启用',
|
||||
@ -1149,13 +1149,31 @@ export default {
|
||||
description: '描述',
|
||||
command: '命令',
|
||||
args: '参数 (JSON 数组)',
|
||||
env: '环境变量 (JSON 对象)',
|
||||
env: '环境变量',
|
||||
toolParseMode: 'Tool 解析模式',
|
||||
enabled: '启用',
|
||||
},
|
||||
env: {
|
||||
formMode: '编辑 JSON',
|
||||
jsonMode: '回到表单',
|
||||
suggested: '推荐',
|
||||
addRow: '添加变量',
|
||||
keyPlaceholder: 'KEY_NAME',
|
||||
valuePlaceholder: '值(如 sk-…)',
|
||||
show: '显示',
|
||||
hide: '隐藏',
|
||||
remove: '移除',
|
||||
hints: {
|
||||
anthropic: 'Anthropic API key(sk-ant-…)。申请: https://console.anthropic.com/settings/keys',
|
||||
openai: 'OpenAI API key(sk-…)。申请: https://platform.openai.com/api-keys',
|
||||
dashscope: '阿里云 DashScope key — 跟 MateClaw .env 里的 DASHSCOPE_API_KEY 同一个值',
|
||||
google: 'Google AI Studio API key。申请: https://aistudio.google.com/apikey',
|
||||
claudeOauth: '注意: claude-code 的 OAuth 登录(~/.claude/ 里那个)在这里不能用——更糟的是 Claude Agent SDK 会"优先读 OAuth、再读 API key",导致你下面填的 key 被静默忽略。如果你这台机器上跑过 `claude login`,先清钥匙串: (macOS) `claude logout` 或 `security delete-generic-password -s "Claude Code-credentials"`;(Linux/Windows) 删 ~/.claude/credentials.json。然后再回来填真正的 API key(sk-ant-…)。或者改用 qwen-code / opencode 复用各自 CLI 的本地登录态。',
|
||||
},
|
||||
},
|
||||
modal: {
|
||||
newTitle: '新增 ACP Endpoint',
|
||||
editTitle: '编辑 ACP Endpoint',
|
||||
newTitle: '新增 ACP 端点',
|
||||
editTitle: '编辑 ACP 端点',
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
@ -2002,6 +2020,7 @@ export default {
|
||||
all: '全部',
|
||||
builtin: '内置',
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: '动态',
|
||||
},
|
||||
search: {
|
||||
@ -2075,6 +2094,7 @@ export default {
|
||||
types: {
|
||||
builtin: '内置',
|
||||
mcp: 'MCP',
|
||||
acp: 'ACP',
|
||||
dynamic: '动态',
|
||||
},
|
||||
source: {
|
||||
|
||||
@ -131,8 +131,81 @@
|
||||
<input v-model="form.argsJson" class="form-input mono" placeholder='["-y","@zed-industries/codex-acp"]' />
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">{{ t('acp.fields.env') }}</label>
|
||||
<textarea v-model="form.envJson" class="form-input form-textarea mono" rows="2" placeholder='{"OPENAI_API_KEY":"..."}'></textarea>
|
||||
<div class="env-header">
|
||||
<label class="form-label">{{ t('acp.fields.env') }}</label>
|
||||
<button type="button" class="btn-link env-mode-toggle" @click="toggleEnvMode">
|
||||
{{ envJsonMode ? t('acp.env.formMode') : t('acp.env.jsonMode') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Suggestion chips: clicking adds a pre-named row to the form. -->
|
||||
<div v-if="!envJsonMode && envSuggestions.length > 0" class="env-suggestions">
|
||||
<span class="env-suggestions-label">{{ t('acp.env.suggested') }}:</span>
|
||||
<button
|
||||
v-for="sug in envSuggestions"
|
||||
:key="sug.key"
|
||||
type="button"
|
||||
class="env-suggestion-chip"
|
||||
:class="{ 'is-added': hasEnvKey(sug.key) }"
|
||||
:title="sug.hint"
|
||||
:disabled="hasEnvKey(sug.key)"
|
||||
@click="addSuggestedEnv(sug)"
|
||||
>
|
||||
{{ sug.key }}
|
||||
<span class="chip-mark">{{ hasEnvKey(sug.key) ? '✓' : '+' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Visual key/value editor (default mode). -->
|
||||
<div v-if="!envJsonMode" class="env-table">
|
||||
<div v-for="(entry, idx) in envEntries" :key="idx" class="env-row">
|
||||
<input
|
||||
v-model="entry.key"
|
||||
class="form-input mono env-key-input"
|
||||
:placeholder="t('acp.env.keyPlaceholder')"
|
||||
@blur="entry.masked = isSecretKey(entry.key)"
|
||||
/>
|
||||
<div class="env-value-wrap">
|
||||
<input
|
||||
v-model="entry.value"
|
||||
:type="entry.masked && !entry.revealed ? 'password' : 'text'"
|
||||
class="form-input mono env-value-input"
|
||||
:placeholder="t('acp.env.valuePlaceholder')"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
v-if="entry.masked"
|
||||
type="button"
|
||||
class="env-eye"
|
||||
@click="entry.revealed = !entry.revealed"
|
||||
:title="entry.revealed ? t('acp.env.hide') : t('acp.env.show')"
|
||||
>
|
||||
{{ entry.revealed ? '🙈' : '👁' }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="env-remove"
|
||||
@click="removeEnvEntry(idx)"
|
||||
:title="t('acp.env.remove')"
|
||||
>×</button>
|
||||
</div>
|
||||
<button type="button" class="env-add-btn" @click="addEnvEntry">
|
||||
+ {{ t('acp.env.addRow') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Raw JSON fallback for advanced users. -->
|
||||
<textarea
|
||||
v-else
|
||||
v-model="form.envJson"
|
||||
class="form-input form-textarea mono"
|
||||
rows="3"
|
||||
placeholder='{"OPENAI_API_KEY":"..."}'
|
||||
></textarea>
|
||||
|
||||
<!-- Endpoint-specific hint (e.g. claude-code OAuth caveat). -->
|
||||
<div v-if="envHint" class="env-hint">💡 {{ envHint }}</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="toggle-inline">
|
||||
@ -152,7 +225,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { mcConfirm } from '@/components/common/useConfirm'
|
||||
@ -197,6 +270,156 @@ const form = reactive<any>(defaultForm())
|
||||
|
||||
const canSave = computed(() => !!form.name && !!form.command)
|
||||
|
||||
// ==================== Env editor state ====================
|
||||
//
|
||||
// The env field used to be a raw JSON textarea — easy to mistype the
|
||||
// quotes / commas / brace and impossible for users to discover which
|
||||
// API key they actually need to paste. The visual editor below replaces
|
||||
// that with a key/value table + per-endpoint suggestion chips, and
|
||||
// keeps a "Edit as JSON" toggle so advanced users can still drop into
|
||||
// a textarea when they want.
|
||||
//
|
||||
// Source-of-truth contract:
|
||||
// - In form mode: envEntries is the truth; we serialize it into
|
||||
// form.envJson at save time (and on toggle).
|
||||
// - In JSON mode: form.envJson is the truth; we parse it back to
|
||||
// envEntries when toggling off.
|
||||
|
||||
interface EnvEntry {
|
||||
key: string
|
||||
value: string
|
||||
masked: boolean
|
||||
revealed: boolean
|
||||
}
|
||||
|
||||
interface EnvSuggestion {
|
||||
key: string
|
||||
hint: string
|
||||
}
|
||||
|
||||
const envEntries = ref<EnvEntry[]>([])
|
||||
const envJsonMode = ref(false)
|
||||
|
||||
/**
|
||||
* Treat any key whose name implies "secret" as masked by default. The
|
||||
* user can still toggle visibility on the row. The pattern matches the
|
||||
* obvious cases (API_KEY / TOKEN / SECRET / PASS*) — false negatives
|
||||
* just show the value in plaintext, which is no worse than the old
|
||||
* JSON textarea did.
|
||||
*/
|
||||
function isSecretKey(key: string): boolean {
|
||||
if (!key) return false
|
||||
return /(KEY|TOKEN|SECRET|PASS|CREDENTIAL)/i.test(key)
|
||||
}
|
||||
|
||||
function entriesFromJson(json: string): EnvEntry[] {
|
||||
if (!json || !json.trim()) return []
|
||||
try {
|
||||
const obj = JSON.parse(json)
|
||||
if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return []
|
||||
return Object.entries(obj).map(([k, v]) => ({
|
||||
key: k,
|
||||
value: typeof v === 'string' ? v : String(v),
|
||||
masked: isSecretKey(k),
|
||||
revealed: false,
|
||||
}))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function entriesToJson(entries: EnvEntry[]): string {
|
||||
const obj: Record<string, string> = {}
|
||||
for (const e of entries) {
|
||||
const k = e.key.trim()
|
||||
if (k) obj[k] = e.value
|
||||
}
|
||||
return JSON.stringify(obj)
|
||||
}
|
||||
|
||||
function addEnvEntry() {
|
||||
envEntries.value.push({ key: '', value: '', masked: false, revealed: false })
|
||||
}
|
||||
|
||||
function removeEnvEntry(idx: number) {
|
||||
envEntries.value.splice(idx, 1)
|
||||
}
|
||||
|
||||
function hasEnvKey(key: string): boolean {
|
||||
return envEntries.value.some((e) => e.key === key)
|
||||
}
|
||||
|
||||
function addSuggestedEnv(sug: EnvSuggestion) {
|
||||
if (hasEnvKey(sug.key)) return
|
||||
envEntries.value.push({
|
||||
key: sug.key,
|
||||
value: '',
|
||||
masked: isSecretKey(sug.key),
|
||||
revealed: false,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggestion chips per endpoint — mirrors the server-side
|
||||
* AcpRuntimeSupport.expectedAuthEnvVar() so the auth-error translator
|
||||
* and the form helper agree on what env var each endpoint expects.
|
||||
*/
|
||||
const envSuggestions = computed<EnvSuggestion[]>(() => {
|
||||
const slug = String(form.name || '').toLowerCase()
|
||||
const cmd = String(form.command || '').toLowerCase()
|
||||
const args = String(form.argsJson || '').toLowerCase()
|
||||
const matches = (kw: string) => slug.includes(kw) || cmd.includes(kw) || args.includes(kw)
|
||||
|
||||
if (matches('claude') || matches('anthropic')) {
|
||||
return [{ key: 'ANTHROPIC_API_KEY', hint: t('acp.env.hints.anthropic') }]
|
||||
}
|
||||
if (matches('codex') || matches('openai')) {
|
||||
return [{ key: 'OPENAI_API_KEY', hint: t('acp.env.hints.openai') }]
|
||||
}
|
||||
if (matches('qwen') || matches('dashscope')) {
|
||||
return [{ key: 'DASHSCOPE_API_KEY', hint: t('acp.env.hints.dashscope') }]
|
||||
}
|
||||
if (matches('gemini') || matches('google')) {
|
||||
return [{ key: 'GOOGLE_API_KEY', hint: t('acp.env.hints.google') }]
|
||||
}
|
||||
// opencode is multi-provider; surface a soft hint instead of a chip
|
||||
// because we don't know which provider the user has configured.
|
||||
return []
|
||||
})
|
||||
|
||||
/**
|
||||
* Caveat banner shown beneath the env editor. Today it only fires for
|
||||
* claude-code endpoints, where users are most likely to confuse OAuth
|
||||
* login (which doesn't authenticate against the public API) with the
|
||||
* env var the third-party Zed wrapper actually needs.
|
||||
*/
|
||||
const envHint = computed(() => {
|
||||
const slug = String(form.name || '').toLowerCase()
|
||||
if (slug.includes('claude')) return t('acp.env.hints.claudeOauth')
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* Switch between form and JSON. The truth-of-record swaps direction;
|
||||
* the watch below performs the bridging conversion so the user never
|
||||
* sees stale data on the other side.
|
||||
*/
|
||||
function toggleEnvMode() {
|
||||
envJsonMode.value = !envJsonMode.value
|
||||
}
|
||||
|
||||
watch(envJsonMode, (isJson, wasJson) => {
|
||||
if (isJson && !wasJson) {
|
||||
// form → JSON: serialize entries; the textarea now owns the value.
|
||||
form.envJson = entriesToJson(envEntries.value)
|
||||
} else if (!isJson && wasJson) {
|
||||
// JSON → form: parse textarea back into rows; if the JSON is
|
||||
// invalid, keep the entries empty rather than throw — the user can
|
||||
// fix the JSON and toggle again.
|
||||
envEntries.value = entriesFromJson(form.envJson)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(loadEndpoints)
|
||||
|
||||
async function loadEndpoints() {
|
||||
@ -221,6 +444,8 @@ function argsPreview(ep: AcpEndpoint): string {
|
||||
function openCreateModal() {
|
||||
editing.value = null
|
||||
Object.assign(form, defaultForm())
|
||||
envEntries.value = []
|
||||
envJsonMode.value = false
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@ -236,6 +461,10 @@ function openEditModal(ep: AcpEndpoint) {
|
||||
toolParseMode: ep.toolParseMode || 'call_title',
|
||||
enabled: !!ep.enabled,
|
||||
})
|
||||
// Always open in form mode so users see the structured editor first.
|
||||
// The "Edit as JSON" toggle is one click away if they need it.
|
||||
envEntries.value = entriesFromJson(form.envJson)
|
||||
envJsonMode.value = false
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@ -245,6 +474,12 @@ function closeModal() {
|
||||
}
|
||||
|
||||
async function saveEndpoint() {
|
||||
// In form mode, the entries array is the truth — serialize it back
|
||||
// into envJson before validating. In JSON mode the textarea is the
|
||||
// truth and we validate it as-is.
|
||||
if (!envJsonMode.value) {
|
||||
form.envJson = entriesToJson(envEntries.value)
|
||||
}
|
||||
// Sanity-check args/env are valid JSON before sending; the server
|
||||
// will tolerate empty strings, but we'd rather fail fast in UI.
|
||||
try {
|
||||
@ -384,4 +619,59 @@ td .status-error { background: none; color: var(--mc-text-tertiary); font-size:
|
||||
.form-textarea { resize: vertical; }
|
||||
.toggle-inline { display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.modal-footer { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 22px; border-top: 1px solid var(--mc-border-light); }
|
||||
|
||||
/* ---------- Visual env editor ---------- */
|
||||
.env-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; }
|
||||
.env-mode-toggle { font-size: 11px; padding: 0; }
|
||||
|
||||
.env-suggestions {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
|
||||
margin: 4px 0 8px; padding: 7px 10px;
|
||||
background: var(--mc-primary-bg); border-radius: 8px; font-size: 12px;
|
||||
}
|
||||
.env-suggestions-label { color: var(--mc-text-secondary); font-weight: 600; }
|
||||
.env-suggestion-chip {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 3px 9px;
|
||||
background: var(--mc-bg-elevated); border: 1px solid var(--mc-border);
|
||||
border-radius: 999px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 11px; cursor: pointer; color: var(--mc-text-primary);
|
||||
transition: 0.15s;
|
||||
}
|
||||
.env-suggestion-chip:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
.env-suggestion-chip:disabled { cursor: default; }
|
||||
.env-suggestion-chip.is-added {
|
||||
background: rgba(34, 197, 94, 0.12); color: #16a34a;
|
||||
border-color: transparent;
|
||||
}
|
||||
.env-suggestion-chip .chip-mark { font-weight: 700; opacity: 0.7; }
|
||||
|
||||
.env-table { display: flex; flex-direction: column; gap: 6px; }
|
||||
.env-row { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(120px, 1.4fr) 24px; gap: 6px; align-items: center; }
|
||||
.env-key-input, .env-value-input { font-size: 12px; padding: 7px 9px; }
|
||||
.env-value-wrap { position: relative; display: flex; }
|
||||
.env-value-input { padding-right: 32px; flex: 1; }
|
||||
.env-eye {
|
||||
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
|
||||
border: none; background: none; font-size: 13px; cursor: pointer; padding: 2px;
|
||||
width: 24px; height: 24px; line-height: 1; border-radius: 4px;
|
||||
}
|
||||
.env-eye:hover { background: var(--mc-bg-sunken); }
|
||||
.env-remove {
|
||||
width: 24px; height: 24px; border: none; background: none; cursor: pointer;
|
||||
color: var(--mc-text-tertiary); border-radius: 6px; font-size: 16px; line-height: 1;
|
||||
}
|
||||
.env-remove:hover { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||
.env-add-btn {
|
||||
padding: 6px; background: none; border: 1px dashed var(--mc-border);
|
||||
border-radius: 8px; color: var(--mc-text-secondary); font-size: 12px;
|
||||
cursor: pointer; transition: 0.15s;
|
||||
}
|
||||
.env-add-btn:hover { border-color: var(--mc-primary); color: var(--mc-primary); }
|
||||
.env-hint {
|
||||
margin-top: 8px; padding: 7px 10px;
|
||||
background: var(--mc-primary-bg); border-radius: 6px;
|
||||
font-size: 11px; color: var(--mc-text-secondary); line-height: 1.55;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
|
||||
<div class="agent-selector">
|
||||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen" :title="`${$t('chat.selectAgent')} (⌘K)`">
|
||||
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🤖' }}</span>
|
||||
<span class="agent-select-trigger__icon"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🤖'" /></span>
|
||||
<span v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__name">{{ currentAgent?.name || $t('chat.selectAgent') }}</span>
|
||||
<svg v-if="!convPanelCollapsed || isMobile" class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
@ -154,7 +154,7 @@
|
||||
<div class="chat-stage-copy" v-if="currentAgent">
|
||||
<div class="chat-stage-kicker">{{ $t('nav.chat') }}</div>
|
||||
<div class="agent-badge" :title="currentAgent.name">
|
||||
<span class="agent-badge-icon">{{ currentAgent.icon || '🤖' }}</span>
|
||||
<span class="agent-badge-icon"><SkillIcon :value="currentAgent.icon" :size="22" :fallback="'🤖'" /></span>
|
||||
<span class="agent-badge-name">{{ currentAgent.name }}</span>
|
||||
<span class="agent-badge-type">{{ currentAgent.agentType === 'react' ? 'ReAct' : 'Plan-Execute' }}</span>
|
||||
<span class="status-dot" :class="connectionStatusClass" :title="connectionStatusLabel"></span>
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
<!-- Agent selector (ChatConsole pattern) -->
|
||||
<div class="agent-selector">
|
||||
<button class="agent-select-trigger" @click="agentDropdownOpen = !agentDropdownOpen">
|
||||
<span class="agent-select-trigger__icon">{{ currentAgent?.icon || '🧠' }}</span>
|
||||
<span class="agent-select-trigger__icon"><SkillIcon :value="currentAgent?.icon" :size="24" :fallback="'🧠'" /></span>
|
||||
<span class="agent-select-trigger__name">{{ currentAgent?.name || t('memory.selectAgent') }}</span>
|
||||
<svg class="agent-select-trigger__arrow" :class="{ open: agentDropdownOpen }" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
||||
</button>
|
||||
|
||||
@ -861,6 +861,9 @@ const categoryTabs = computed(() => [
|
||||
{ label: t('skills.tabs.all'), value: 'all', icon: '🗂️' },
|
||||
{ label: t('skills.tabs.builtin'), value: 'builtin', icon: '🔧' },
|
||||
{ label: t('skills.tabs.mcp'), value: 'mcp', icon: '🔌' },
|
||||
// ACP (Agent Communication Protocol) — auto-bridged from
|
||||
// Settings ▸ ACP Endpoints; one card per enabled endpoint.
|
||||
{ label: t('skills.tabs.acp'), value: 'acp', icon: '🤝' },
|
||||
{ label: t('skills.tabs.dynamic'), value: 'dynamic', icon: '📦' },
|
||||
])
|
||||
|
||||
@ -1473,19 +1476,24 @@ function needsSetup(skill: Skill): boolean {
|
||||
}
|
||||
|
||||
function getSkillIcon(type: string) {
|
||||
return { builtin: '🔧', mcp: '🔌', dynamic: '📦' }[type] ?? '🛠️'
|
||||
return { builtin: '🔧', mcp: '🔌', acp: '🤝', dynamic: '📦' }[type] ?? '🛠️'
|
||||
}
|
||||
|
||||
function getSkillIconBg(type: string) {
|
||||
return { builtin: 'bg-blue', mcp: 'bg-purple', dynamic: 'bg-green' }[type] ?? 'bg-gray'
|
||||
return { builtin: 'bg-blue', mcp: 'bg-purple', acp: 'bg-orange', dynamic: 'bg-green' }[type] ?? 'bg-gray'
|
||||
}
|
||||
|
||||
function getSkillTypeBadge(type: string) {
|
||||
return { builtin: 'badge-blue', mcp: 'badge-purple', dynamic: 'badge-green' }[type] ?? 'badge-gray'
|
||||
return { builtin: 'badge-blue', mcp: 'badge-purple', acp: 'badge-orange', dynamic: 'badge-green' }[type] ?? 'badge-gray'
|
||||
}
|
||||
|
||||
function getSkillTypeLabel(type: string) {
|
||||
const map: Record<string, string> = { builtin: t('skills.types.builtin'), mcp: t('skills.types.mcp'), dynamic: t('skills.types.dynamic') }
|
||||
const map: Record<string, string> = {
|
||||
builtin: t('skills.types.builtin'),
|
||||
mcp: t('skills.types.mcp'),
|
||||
acp: t('skills.types.acp'),
|
||||
dynamic: t('skills.types.dynamic'),
|
||||
}
|
||||
return map[type] ?? type
|
||||
}
|
||||
</script>
|
||||
@ -1676,6 +1684,7 @@ html.dark .scan-finding-item { background: rgba(255, 255, 255, 0.05); }
|
||||
.skill-icon-wrap { width: 44px; height: 44px; border-radius: 14px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
|
||||
.bg-blue { background: var(--mc-primary-bg); }
|
||||
.bg-purple { background: var(--mc-primary-bg); }
|
||||
.bg-orange { background: var(--mc-primary-bg); }
|
||||
.bg-green { background: var(--mc-primary-bg); }
|
||||
.bg-gray { background: var(--mc-bg-sunken); }
|
||||
.skill-icon { font-size: 20px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user