mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(skill): ACP integration for external coding agents
This commit is contained in:
parent
f0991f543f
commit
020a87ee7e
@ -0,0 +1,289 @@
|
||||
package vip.mate.acp.client;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.Writer;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — minimal Java ACP (Agent Communication Protocol)
|
||||
* client over stdio.
|
||||
*
|
||||
* <p>Implements just enough of the JSON-RPC 2.0 framing to:
|
||||
* <ol>
|
||||
* <li>Spawn the agent process ({@code command} + {@code args}).</li>
|
||||
* <li>Send {@code initialize} and capture {@code agentCapabilities}
|
||||
* / {@code protocolVersion}.</li>
|
||||
* <li>Optionally open a {@code session/new} handshake.</li>
|
||||
* <li>Tear the process down cleanly.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>This is intentionally a one-shot connection tester (RFC §10.2 Q3
|
||||
* recommended starting order: codex → claude → opencode → qwen). Full
|
||||
* bidirectional session prompting / streaming / permission requests is
|
||||
* a future increment — that needs a proper async bus and ties into the
|
||||
* agent graph layer.
|
||||
*
|
||||
* <p>Why not the official {@code acp} Python SDK: MateClaw runs on the
|
||||
* JVM. The protocol is JSON-RPC 2.0 line-delimited over stdio (per the
|
||||
* QwenPaw reference at {@code C:/codes/QwenPaw}); the surface we need
|
||||
* for "test connection" is small enough to implement directly.
|
||||
*
|
||||
* <p>Each {@link AcpStdioClient} instance owns one Process. Use
|
||||
* try-with-resources or call {@link #close()} explicitly.
|
||||
*/
|
||||
@Slf4j
|
||||
public class AcpStdioClient implements AutoCloseable {
|
||||
|
||||
/** ACP protocol version we advertise (matches QwenPaw v1 + Zed agents). */
|
||||
public static final int PROTOCOL_VERSION = 1;
|
||||
|
||||
private final ObjectMapper mapper;
|
||||
private final Process process;
|
||||
private final Writer stdin;
|
||||
private final BufferedReader stdout;
|
||||
private final Thread readerThread;
|
||||
private final AtomicLong nextRequestId = new AtomicLong(1);
|
||||
private final Map<Long, CompletableFuture<JsonNode>> pending = new ConcurrentHashMap<>();
|
||||
private volatile boolean closed = false;
|
||||
|
||||
private AcpStdioClient(ObjectMapper mapper, Process process) {
|
||||
this.mapper = mapper;
|
||||
this.process = process;
|
||||
this.stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);
|
||||
this.stdout = new BufferedReader(
|
||||
new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8));
|
||||
this.readerThread = new Thread(this::readLoop, "acp-stdio-reader");
|
||||
this.readerThread.setDaemon(true);
|
||||
this.readerThread.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the configured agent process. Caller is responsible for
|
||||
* closing the returned client; failure to do so leaks a child
|
||||
* process.
|
||||
*/
|
||||
public static AcpStdioClient spawn(ObjectMapper mapper,
|
||||
String command,
|
||||
List<String> args,
|
||||
Map<String, String> envOverrides,
|
||||
String cwd)
|
||||
throws IOException {
|
||||
if (command == null || command.isBlank()) {
|
||||
throw new IllegalArgumentException("ACP command is required");
|
||||
}
|
||||
java.util.List<String> cmdline = new java.util.ArrayList<>();
|
||||
cmdline.add(command);
|
||||
if (args != null) cmdline.addAll(args);
|
||||
ProcessBuilder pb = new ProcessBuilder(cmdline);
|
||||
Map<String, String> env = pb.environment();
|
||||
if (envOverrides != null) env.putAll(envOverrides);
|
||||
if (cwd != null && !cwd.isBlank()) {
|
||||
pb.directory(new java.io.File(cwd));
|
||||
}
|
||||
// Keep stderr separate from stdout so we don't poison JSON-RPC
|
||||
// framing when the child agent writes a banner / log line.
|
||||
pb.redirectErrorStream(false);
|
||||
Process proc = pb.start();
|
||||
// Drain stderr in the background — many CLIs print diagnostics
|
||||
// there (e.g. Zed agents print version on startup).
|
||||
Thread errDrain = new Thread(() -> drainStream(proc.getErrorStream()), "acp-stdio-stderr");
|
||||
errDrain.setDaemon(true);
|
||||
errDrain.start();
|
||||
return new AcpStdioClient(mapper, proc);
|
||||
}
|
||||
|
||||
private static void drainStream(java.io.InputStream in) {
|
||||
try (BufferedReader br = new BufferedReader(
|
||||
new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (log.isDebugEnabled()) log.debug("[acp-stderr] {}", line);
|
||||
}
|
||||
} catch (IOException ignore) {
|
||||
// Process exited; nothing to do.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send {@code initialize} and wait for the response. Returns the
|
||||
* response payload's {@code result} object, or throws on protocol
|
||||
* mismatch / timeout.
|
||||
*/
|
||||
public JsonNode initialize(long timeoutMillis) throws IOException, InterruptedException {
|
||||
ObjectNode params = mapper.createObjectNode();
|
||||
params.put("protocolVersion", PROTOCOL_VERSION);
|
||||
// ClientCapabilities — we don't yet implement any client-side
|
||||
// optional features. Send an empty object so strict agents
|
||||
// don't reject the request.
|
||||
params.set("clientCapabilities", mapper.createObjectNode());
|
||||
ObjectNode info = mapper.createObjectNode();
|
||||
info.put("name", "mateclaw-acp-client");
|
||||
info.put("version", "1.0.0");
|
||||
params.set("clientInfo", info);
|
||||
return sendRequest("initialize", params, timeoutMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
public JsonNode newSession(String cwd, long timeoutMillis)
|
||||
throws IOException, InterruptedException {
|
||||
ObjectNode params = mapper.createObjectNode();
|
||||
if (cwd != null && !cwd.isBlank()) params.put("cwd", cwd);
|
||||
params.set("mcpServers", mapper.createArrayNode());
|
||||
return sendRequest("session/new", params, timeoutMillis);
|
||||
}
|
||||
|
||||
/**
|
||||
* Lower-level request helper. Synchronously awaits the response
|
||||
* matching the request id. Server-pushed requests (e.g. permission
|
||||
* prompts) are dropped — the test-only connection path doesn't need
|
||||
* to handle them.
|
||||
*/
|
||||
public JsonNode sendRequest(String method, JsonNode params, long timeoutMillis)
|
||||
throws IOException, InterruptedException {
|
||||
if (closed) throw new IOException("ACP client is closed");
|
||||
long id = nextRequestId.getAndIncrement();
|
||||
CompletableFuture<JsonNode> future = new CompletableFuture<>();
|
||||
pending.put(id, future);
|
||||
|
||||
ObjectNode envelope = mapper.createObjectNode();
|
||||
envelope.put("jsonrpc", "2.0");
|
||||
envelope.put("id", id);
|
||||
envelope.put("method", method);
|
||||
envelope.set("params", params);
|
||||
|
||||
synchronized (stdin) {
|
||||
stdin.write(mapper.writeValueAsString(envelope));
|
||||
stdin.write('\n');
|
||||
stdin.flush();
|
||||
}
|
||||
|
||||
try {
|
||||
return future.get(timeoutMillis, TimeUnit.MILLISECONDS);
|
||||
} catch (java.util.concurrent.ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
if (cause instanceof IOException io) throw io;
|
||||
throw new IOException("ACP request failed: " + (cause != null ? cause.getMessage() : "unknown"));
|
||||
} catch (java.util.concurrent.TimeoutException e) {
|
||||
pending.remove(id);
|
||||
throw new IOException("ACP request timed out after " + timeoutMillis + "ms");
|
||||
}
|
||||
}
|
||||
|
||||
private void readLoop() {
|
||||
try {
|
||||
String line;
|
||||
while (!closed && (line = stdout.readLine()) != null) {
|
||||
if (line.isEmpty()) continue;
|
||||
try {
|
||||
JsonNode msg = mapper.readTree(line);
|
||||
routeMessage(msg);
|
||||
} catch (Exception e) {
|
||||
log.warn("ACP malformed line, skipping: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
if (!closed) {
|
||||
log.debug("ACP stdio reader closed: {}", e.getMessage());
|
||||
}
|
||||
} finally {
|
||||
// If the process exited mid-await, fail every pending future.
|
||||
for (Map.Entry<Long, CompletableFuture<JsonNode>> entry : pending.entrySet()) {
|
||||
entry.getValue().completeExceptionally(
|
||||
new IOException("ACP process exited before responding"));
|
||||
}
|
||||
pending.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void routeMessage(JsonNode msg) {
|
||||
JsonNode idNode = msg.get("id");
|
||||
if (idNode != null && idNode.isNumber()) {
|
||||
long id = idNode.asLong();
|
||||
CompletableFuture<JsonNode> future = pending.remove(id);
|
||||
if (future != null) {
|
||||
JsonNode error = msg.get("error");
|
||||
if (error != null && !error.isNull()) {
|
||||
future.completeExceptionally(
|
||||
new IOException("ACP error: " + error.toString()));
|
||||
} else {
|
||||
future.complete(msg.get("result"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Server-initiated request or notification — for the test path
|
||||
// we only need to politely decline. A future bidirectional
|
||||
// session loop will dispatch these to handlers.
|
||||
if (msg.has("method") && msg.has("id")) {
|
||||
// Reply with a "method not implemented" so the agent doesn't hang.
|
||||
try {
|
||||
ObjectNode reply = mapper.createObjectNode();
|
||||
reply.put("jsonrpc", "2.0");
|
||||
reply.set("id", msg.get("id"));
|
||||
ObjectNode error = mapper.createObjectNode();
|
||||
error.put("code", -32601);
|
||||
error.put("message", "Method not implemented in test client: "
|
||||
+ msg.path("method").asText(""));
|
||||
reply.set("error", error);
|
||||
synchronized (stdin) {
|
||||
stdin.write(mapper.writeValueAsString(reply));
|
||||
stdin.write('\n');
|
||||
stdin.flush();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("ACP failed to reply to server-initiated request: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
closed = true;
|
||||
try {
|
||||
stdin.close();
|
||||
} catch (IOException ignore) {
|
||||
/* best effort */
|
||||
}
|
||||
try {
|
||||
// Give the agent ~1s to exit gracefully after EOF on stdin.
|
||||
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||
process.destroy();
|
||||
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
}
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
process.destroyForcibly();
|
||||
}
|
||||
try {
|
||||
stdout.close();
|
||||
} catch (IOException ignore) {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
|
||||
/** Convenience for callers that just want a fresh empty env map. */
|
||||
public static Map<String, String> emptyEnv() {
|
||||
return new HashMap<>();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.acp.controller;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.service.AcpConnectionTester;
|
||||
import vip.mate.acp.service.AcpEndpointService;
|
||||
import vip.mate.common.result.R;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — REST surface for managing ACP endpoints.
|
||||
*
|
||||
* <p>Mirrors the McpServers controller so the frontend page can be a
|
||||
* close cousin of {@code McpServers.vue}.
|
||||
*/
|
||||
@Tag(name = "ACP Endpoints (RFC-090 Phase 7)")
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/acp/endpoints")
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointController {
|
||||
|
||||
private final AcpEndpointService service;
|
||||
private final AcpConnectionTester tester;
|
||||
|
||||
@Operation(summary = "List ACP endpoints")
|
||||
@GetMapping
|
||||
public R<List<AcpEndpointEntity>> list() {
|
||||
return R.ok(service.list());
|
||||
}
|
||||
|
||||
@Operation(summary = "Get ACP endpoint by id")
|
||||
@GetMapping("/{id}")
|
||||
public R<AcpEndpointEntity> get(@PathVariable Long id) {
|
||||
return R.ok(service.get(id));
|
||||
}
|
||||
|
||||
@Operation(summary = "Create a custom ACP endpoint")
|
||||
@PostMapping
|
||||
public R<AcpEndpointEntity> create(@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.create(body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Update an ACP endpoint")
|
||||
@PutMapping("/{id}")
|
||||
public R<AcpEndpointEntity> update(@PathVariable Long id,
|
||||
@RequestBody AcpEndpointEntity body) {
|
||||
return R.ok(service.update(id, body));
|
||||
}
|
||||
|
||||
@Operation(summary = "Delete an ACP endpoint (builtins are protected)")
|
||||
@DeleteMapping("/{id}")
|
||||
public R<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
return R.ok();
|
||||
}
|
||||
|
||||
@Operation(summary = "Enable / disable an ACP endpoint")
|
||||
@PutMapping("/{id}/toggle")
|
||||
public R<AcpEndpointEntity> toggle(@PathVariable Long id,
|
||||
@RequestParam boolean enabled) {
|
||||
return R.ok(service.toggle(id, enabled));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn the configured CLI, run {@code initialize} + {@code
|
||||
* session/new}, persist the outcome, and return diagnostics.
|
||||
*/
|
||||
@Operation(summary = "Test ACP endpoint connection (initialize handshake)")
|
||||
@PostMapping("/{id}/test")
|
||||
public R<Map<String, Object>> test(@PathVariable Long id) {
|
||||
AcpEndpointEntity endpoint = service.get(id);
|
||||
return R.ok(tester.testEndpoint(endpoint));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,79 @@
|
||||
package vip.mate.acp.model;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — ACP (Agent Communication Protocol) endpoint registry.
|
||||
*
|
||||
* <p>Each row describes one external coding agent that MateClaw can
|
||||
* delegate to over stdio (codex / claude-code / opencode / qwen-code by
|
||||
* default). Bundled via Flyway V68 so the user only has to enable the
|
||||
* row once the matching CLI is on their PATH.
|
||||
*/
|
||||
@Data
|
||||
@TableName("mate_acp_endpoint")
|
||||
public class AcpEndpointEntity {
|
||||
|
||||
@TableId(type = IdType.ASSIGN_ID)
|
||||
private Long id;
|
||||
|
||||
/** Stable slug, lowercase. Referenced by skill manifests via {@code type: acp} + {@code endpoint:}. */
|
||||
private String name;
|
||||
|
||||
private String displayName;
|
||||
private String description;
|
||||
|
||||
/** Process command, e.g. {@code npx} or {@code codex}. */
|
||||
private String command;
|
||||
|
||||
/**
|
||||
* JSON array of CLI args, e.g. {@code ["-y","@zed-industries/codex-acp"]}.
|
||||
* MyBatis Plus stores it as a string; the service layer parses on read.
|
||||
*/
|
||||
@TableField(value = "args_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String argsJson;
|
||||
|
||||
/** JSON object of environment variables to inject (merged onto System.getenv()). */
|
||||
@TableField(value = "env_json", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String envJson;
|
||||
|
||||
/**
|
||||
* call_title | call_detail | update_detail (mirrors QwenPaw
|
||||
* {@code tool_parse_mode}). Drives how the wrapper renders ACP
|
||||
* tool-call events into MateClaw's stream protocol.
|
||||
*/
|
||||
private String toolParseMode;
|
||||
|
||||
private Boolean builtin;
|
||||
/** When true, accept the agent's tool calls without re-prompting the user. */
|
||||
private Boolean trusted;
|
||||
private Boolean enabled;
|
||||
|
||||
/** Stdio buffer ceiling in bytes; defaults to 50 MiB. */
|
||||
private Long stdioBufferLimitBytes;
|
||||
|
||||
/** UNKNOWN / OK / ERROR — last test result. */
|
||||
private String lastStatus;
|
||||
|
||||
private LocalDateTime lastTestedAt;
|
||||
@TableField(value = "last_error", updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String lastError;
|
||||
|
||||
private Long workspaceId;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
private Integer deleted;
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package vip.mate.acp.repository;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — MyBatis Plus mapper for {@link AcpEndpointEntity}.
|
||||
*/
|
||||
@Mapper
|
||||
public interface AcpEndpointMapper extends BaseMapper<AcpEndpointEntity> {
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.client.AcpStdioClient;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — connection tester for ACP endpoints.
|
||||
*
|
||||
* <p>Runs the {@code initialize} + {@code session/new} handshake, with
|
||||
* a generous-but-bounded timeout, and persists the outcome on the row.
|
||||
* The wired CLI doesn't have to be installed for the user to add a row;
|
||||
* they can install it later and re-run the test.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AcpConnectionTester {
|
||||
|
||||
/** Hard cap so a hung CLI doesn't block the request thread forever. */
|
||||
private static final long INITIALIZE_TIMEOUT_MS = 15_000L;
|
||||
private static final long SESSION_NEW_TIMEOUT_MS = 10_000L;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final AcpEndpointService endpointService;
|
||||
|
||||
/**
|
||||
* Spawn the configured agent, exchange initialize + session/new,
|
||||
* tear it down, and return a structured result. The endpoint row
|
||||
* is updated with {@code last_status / last_tested_at / last_error}.
|
||||
*/
|
||||
public Map<String, Object> testEndpoint(AcpEndpointEntity endpoint) {
|
||||
long started = System.currentTimeMillis();
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("name", endpoint.getName());
|
||||
result.put("command", endpoint.getCommand());
|
||||
|
||||
List<String> args = endpointService.parseArgs(endpoint);
|
||||
Map<String, String> env = endpointService.parseEnv(endpoint);
|
||||
result.put("args", args);
|
||||
|
||||
AcpStdioClient client;
|
||||
try {
|
||||
client = AcpStdioClient.spawn(objectMapper, endpoint.getCommand(),
|
||||
args, env, /* cwd */ null);
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Spawn failed: " + e.getMessage(), started);
|
||||
}
|
||||
|
||||
try (AcpStdioClient autoClose = client) {
|
||||
JsonNode initResp;
|
||||
try {
|
||||
initResp = autoClose.initialize(INITIALIZE_TIMEOUT_MS);
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Initialize failed: " + e.getMessage(), started);
|
||||
}
|
||||
if (initResp == null) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Initialize returned no result", started);
|
||||
}
|
||||
int agentProtocolVersion = initResp.path("protocolVersion").asInt(-1);
|
||||
result.put("protocolVersion", agentProtocolVersion);
|
||||
if (agentProtocolVersion != AcpStdioClient.PROTOCOL_VERSION) {
|
||||
String msg = "Protocol mismatch: agent=" + agentProtocolVersion
|
||||
+ ", client=" + AcpStdioClient.PROTOCOL_VERSION;
|
||||
return persistAndReturn(endpoint, result, "ERROR", msg, started);
|
||||
}
|
||||
// Capture agent capabilities for diagnostics — the UI can
|
||||
// surface this as "supports: file_system, terminal, …".
|
||||
JsonNode agentCaps = initResp.path("agentCapabilities");
|
||||
if (!agentCaps.isMissingNode() && !agentCaps.isNull()) {
|
||||
result.put("agentCapabilities", agentCaps);
|
||||
}
|
||||
|
||||
// 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);
|
||||
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());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
return persistAndReturn(endpoint, result, "ERROR",
|
||||
"Connection test crashed: " + e.getMessage(), started);
|
||||
}
|
||||
|
||||
long elapsed = System.currentTimeMillis() - started;
|
||||
result.put("elapsedMs", elapsed);
|
||||
return persistAndReturn(endpoint, result, "OK", null, started);
|
||||
}
|
||||
|
||||
private Map<String, Object> persistAndReturn(AcpEndpointEntity endpoint,
|
||||
Map<String, Object> result,
|
||||
String status,
|
||||
String error,
|
||||
long started) {
|
||||
endpointService.recordTestResult(endpoint.getId(), status, error);
|
||||
result.put("status", status);
|
||||
if (error != null) result.put("error", error);
|
||||
result.putIfAbsent("elapsedMs", System.currentTimeMillis() - started);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,162 @@
|
||||
package vip.mate.acp.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import vip.mate.acp.model.AcpEndpointEntity;
|
||||
import vip.mate.acp.repository.AcpEndpointMapper;
|
||||
import vip.mate.exception.MateClawException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* RFC-090 Phase 7 — CRUD layer for {@link AcpEndpointEntity}.
|
||||
*
|
||||
* <p>Keeps three guarantees:
|
||||
* <ol>
|
||||
* <li>Builtin rows ({@code builtin=true}) cannot be hard-deleted —
|
||||
* the user can only disable them. Mirrors {@code SkillService}.</li>
|
||||
* <li>Names are unique; {@code create} validates against the live
|
||||
* (non-deleted) set.</li>
|
||||
* <li>{@code argsJson} / {@code envJson} round-trip through Jackson
|
||||
* so the controller can hand structured data to the UI without
|
||||
* leaking string-encoded JSON.</li>
|
||||
* </ol>
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AcpEndpointService {
|
||||
|
||||
private final AcpEndpointMapper mapper;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public List<AcpEndpointEntity> list() {
|
||||
return mapper.selectList(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.orderByDesc(AcpEndpointEntity::getBuiltin)
|
||||
.orderByAsc(AcpEndpointEntity::getName));
|
||||
}
|
||||
|
||||
public AcpEndpointEntity get(Long id) {
|
||||
AcpEndpointEntity ep = mapper.selectById(id);
|
||||
if (ep == null) throw new MateClawException("err.acp.endpoint_not_found",
|
||||
"ACP endpoint not found: " + id);
|
||||
return ep;
|
||||
}
|
||||
|
||||
public AcpEndpointEntity findByName(String name) {
|
||||
return mapper.selectOne(new LambdaQueryWrapper<AcpEndpointEntity>()
|
||||
.eq(AcpEndpointEntity::getName, name));
|
||||
}
|
||||
|
||||
public AcpEndpointEntity create(AcpEndpointEntity input) {
|
||||
if (input.getName() == null || input.getName().isBlank()) {
|
||||
throw new MateClawException("err.acp.name_required", "ACP endpoint name is required");
|
||||
}
|
||||
if (input.getCommand() == null || input.getCommand().isBlank()) {
|
||||
throw new MateClawException("err.acp.command_required", "ACP endpoint command is required");
|
||||
}
|
||||
if (findByName(input.getName()) != null) {
|
||||
throw new MateClawException("err.acp.name_exists",
|
||||
"ACP endpoint name already exists: " + input.getName());
|
||||
}
|
||||
// User-created rows are never builtin; default-enable false so a
|
||||
// misconfigured row can't auto-spawn a process at startup.
|
||||
input.setBuiltin(false);
|
||||
if (input.getEnabled() == null) input.setEnabled(false);
|
||||
if (input.getTrusted() == null) input.setTrusted(true);
|
||||
if (input.getToolParseMode() == null || input.getToolParseMode().isBlank()) {
|
||||
input.setToolParseMode("call_title");
|
||||
}
|
||||
if (input.getStdioBufferLimitBytes() == null || input.getStdioBufferLimitBytes() <= 0) {
|
||||
input.setStdioBufferLimitBytes(50L * 1024L * 1024L);
|
||||
}
|
||||
if (input.getWorkspaceId() == null) input.setWorkspaceId(1L);
|
||||
mapper.insert(input);
|
||||
log.info("Created ACP endpoint: {}", input.getName());
|
||||
return input;
|
||||
}
|
||||
|
||||
public AcpEndpointEntity update(Long id, AcpEndpointEntity patch) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())
|
||||
&& patch.getCommand() != null
|
||||
&& !patch.getCommand().equals(existing.getCommand())) {
|
||||
throw new MateClawException("err.acp.builtin_command_locked",
|
||||
"Builtin ACP endpoint command cannot be changed: " + existing.getName());
|
||||
}
|
||||
// Allow surgical updates: only fields the caller actually set.
|
||||
if (patch.getDisplayName() != null) existing.setDisplayName(patch.getDisplayName());
|
||||
if (patch.getDescription() != null) existing.setDescription(patch.getDescription());
|
||||
if (patch.getCommand() != null) existing.setCommand(patch.getCommand());
|
||||
if (patch.getArgsJson() != null) existing.setArgsJson(patch.getArgsJson());
|
||||
if (patch.getEnvJson() != null) existing.setEnvJson(patch.getEnvJson());
|
||||
if (patch.getToolParseMode() != null) existing.setToolParseMode(patch.getToolParseMode());
|
||||
if (patch.getTrusted() != null) existing.setTrusted(patch.getTrusted());
|
||||
if (patch.getEnabled() != null) existing.setEnabled(patch.getEnabled());
|
||||
if (patch.getStdioBufferLimitBytes() != null && patch.getStdioBufferLimitBytes() > 0) {
|
||||
existing.setStdioBufferLimitBytes(patch.getStdioBufferLimitBytes());
|
||||
}
|
||||
mapper.updateById(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
public void delete(Long id) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
if (Boolean.TRUE.equals(existing.getBuiltin())) {
|
||||
throw new MateClawException("err.acp.builtin_readonly",
|
||||
"Builtin ACP endpoint cannot be deleted: " + existing.getName());
|
||||
}
|
||||
mapper.deleteById(id);
|
||||
log.info("Deleted ACP endpoint: {}", existing.getName());
|
||||
}
|
||||
|
||||
public AcpEndpointEntity toggle(Long id, boolean enabled) {
|
||||
AcpEndpointEntity existing = get(id);
|
||||
existing.setEnabled(enabled);
|
||||
mapper.updateById(existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
/** Persist a connection-test outcome on the row. */
|
||||
public void recordTestResult(Long id, String status, String error) {
|
||||
AcpEndpointEntity existing = mapper.selectById(id);
|
||||
if (existing == null) return;
|
||||
existing.setLastStatus(status);
|
||||
existing.setLastTestedAt(LocalDateTime.now());
|
||||
existing.setLastError(error);
|
||||
mapper.updateById(existing);
|
||||
}
|
||||
|
||||
public List<String> parseArgs(AcpEndpointEntity ep) {
|
||||
return parseStringList(ep.getArgsJson());
|
||||
}
|
||||
|
||||
public Map<String, String> parseEnv(AcpEndpointEntity ep) {
|
||||
if (ep.getEnvJson() == null || ep.getEnvJson().isBlank()) return Map.of();
|
||||
try {
|
||||
return objectMapper.readValue(ep.getEnvJson(),
|
||||
new TypeReference<Map<String, String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse env_json for ACP endpoint '{}': {}",
|
||||
ep.getName(), e.getMessage());
|
||||
return Map.of();
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> parseStringList(String json) {
|
||||
if (json == null || json.isBlank()) return Collections.emptyList();
|
||||
try {
|
||||
return objectMapper.readValue(json, new TypeReference<List<String>>() {});
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse args_json: {}", e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
-- V68: ACP (Agent Communication Protocol) endpoint registry (RFC-090 Phase 7)
|
||||
-- One row per external coding agent the user can delegate to over stdio.
|
||||
-- The 4 default rows mirror QwenPaw's bundled set (codex / claude-code / opencode / qwen-code).
|
||||
CREATE TABLE IF NOT EXISTS mate_acp_endpoint (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(128),
|
||||
description TEXT,
|
||||
-- Process command + args. args_json is a JSON array string.
|
||||
command VARCHAR(256) NOT NULL,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
-- Bookkeeping for the per-call parser.
|
||||
-- One of call_title | call_detail | update_detail (matches QwenPaw tool_parse_mode).
|
||||
tool_parse_mode VARCHAR(32) NOT NULL DEFAULT 'call_title',
|
||||
builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
trusted BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
-- Stdio buffer ceiling, default 50 MiB (mirrors QwenPaw default).
|
||||
stdio_buffer_limit_bytes BIGINT NOT NULL DEFAULT 52428800,
|
||||
last_status VARCHAR(32),
|
||||
last_tested_at DATETIME,
|
||||
last_error TEXT,
|
||||
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
-- Seed the 4 builtin endpoints (disabled by default — user opts in
|
||||
-- once they have the matching CLI installed locally). Idempotent via
|
||||
-- MERGE so reruns don't duplicate.
|
||||
MERGE INTO mate_acp_endpoint
|
||||
(id, name, display_name, description, command, args_json, env_json,
|
||||
tool_parse_mode, builtin, trusted, enabled,
|
||||
stdio_buffer_limit_bytes, workspace_id, create_time, update_time, deleted)
|
||||
KEY (name)
|
||||
VALUES
|
||||
(9100001, 'codex', 'OpenAI Codex CLI', 'Delegate to the Codex ACP agent via npx',
|
||||
'npx', '["-y","@zed-industries/codex-acp"]', '{}',
|
||||
'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100002, 'claude-code', 'Claude Code', 'Delegate to Anthropic''s Claude Code agent via npx',
|
||||
'npx', '["-y","@zed-industries/claude-agent-acp"]', '{}',
|
||||
'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100003, 'opencode', 'OpenCode', 'Delegate to OpenCode ACP agent (binary on PATH)',
|
||||
'opencode', '["acp"]', '{}',
|
||||
'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100004, 'qwen-code', 'Qwen Code', 'Delegate to Qwen Code ACP agent (binary on PATH)',
|
||||
'qwen', '["--acp"]', '{}',
|
||||
'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0);
|
||||
@ -0,0 +1,52 @@
|
||||
-- V68: ACP (Agent Communication Protocol) endpoint registry (RFC-090 Phase 7)
|
||||
-- See h2/V68 for column rationale; MySQL needs INSERT ... ON DUPLICATE KEY
|
||||
-- and a unique index on name for the seed merge to be idempotent.
|
||||
CREATE TABLE IF NOT EXISTS mate_acp_endpoint (
|
||||
id BIGINT NOT NULL PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
display_name VARCHAR(128),
|
||||
description TEXT,
|
||||
command VARCHAR(256) NOT NULL,
|
||||
args_json TEXT,
|
||||
env_json TEXT,
|
||||
tool_parse_mode VARCHAR(32) NOT NULL DEFAULT 'call_title',
|
||||
builtin BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
trusted BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
stdio_buffer_limit_bytes BIGINT NOT NULL DEFAULT 52428800,
|
||||
last_status VARCHAR(32),
|
||||
last_tested_at DATETIME,
|
||||
last_error TEXT,
|
||||
workspace_id BIGINT NOT NULL DEFAULT 1,
|
||||
create_time DATETIME NOT NULL,
|
||||
update_time DATETIME NOT NULL,
|
||||
deleted INT NOT NULL DEFAULT 0,
|
||||
UNIQUE KEY uk_acp_endpoint_name (name)
|
||||
);
|
||||
|
||||
INSERT INTO mate_acp_endpoint
|
||||
(id, name, display_name, description, command, args_json, env_json,
|
||||
tool_parse_mode, builtin, trusted, enabled,
|
||||
stdio_buffer_limit_bytes, workspace_id, create_time, update_time, deleted)
|
||||
VALUES
|
||||
(9100001, 'codex', 'OpenAI Codex CLI', 'Delegate to the Codex ACP agent via npx',
|
||||
'npx', '["-y","@zed-industries/codex-acp"]', '{}',
|
||||
'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100002, 'claude-code', 'Claude Code', 'Delegate to Anthropic\'s Claude Code agent via npx',
|
||||
'npx', '["-y","@zed-industries/claude-agent-acp"]', '{}',
|
||||
'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100003, 'opencode', 'OpenCode', 'Delegate to OpenCode ACP agent (binary on PATH)',
|
||||
'opencode', '["acp"]', '{}',
|
||||
'update_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0),
|
||||
(9100004, 'qwen-code', 'Qwen Code', 'Delegate to Qwen Code ACP agent (binary on PATH)',
|
||||
'qwen', '["--acp"]', '{}',
|
||||
'call_detail', TRUE, TRUE, FALSE, 52428800, 1, NOW(), NOW(), 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
display_name = VALUES(display_name),
|
||||
description = VALUES(description),
|
||||
command = VALUES(command),
|
||||
args_json = VALUES(args_json),
|
||||
tool_parse_mode = VALUES(tool_parse_mode),
|
||||
builtin = VALUES(builtin),
|
||||
trusted = VALUES(trusted),
|
||||
update_time = NOW();
|
||||
@ -176,6 +176,18 @@ export const skillApi = {
|
||||
clearLessons: (id: string | number) => http.post(`/skills/${id}/lessons/clear`),
|
||||
}
|
||||
|
||||
// ==================== ACP Endpoints (RFC-090 Phase 7) ====================
|
||||
export const acpApi = {
|
||||
list: () => http.get('/acp/endpoints'),
|
||||
get: (id: number | string) => http.get(`/acp/endpoints/${id}`),
|
||||
create: (data: any) => http.post('/acp/endpoints', data),
|
||||
update: (id: number | string, data: any) => http.put(`/acp/endpoints/${id}`, data),
|
||||
delete: (id: number | string) => http.delete(`/acp/endpoints/${id}`),
|
||||
toggle: (id: number | string, enabled: boolean) =>
|
||||
http.put(`/acp/endpoints/${id}/toggle?enabled=${enabled}`),
|
||||
test: (id: number | string) => http.post(`/acp/endpoints/${id}/test`),
|
||||
}
|
||||
|
||||
// ==================== Skill Templates (RFC-091) ====================
|
||||
export const skillTemplateApi = {
|
||||
list: () => http.get('/skill-templates'),
|
||||
|
||||
@ -248,6 +248,7 @@ export default {
|
||||
mcpConnections: 'MCP Connections',
|
||||
toolsCatalog: 'Tools (Catalog)',
|
||||
activity: 'Activity',
|
||||
acpEndpoints: 'ACP Endpoints',
|
||||
settingsGroup: 'Settings',
|
||||
agents: 'Agents',
|
||||
security: 'Security',
|
||||
@ -1049,6 +1050,45 @@ export default {
|
||||
loadFailed: 'Failed to load token usage',
|
||||
noData: 'No token usage data',
|
||||
},
|
||||
acp: {
|
||||
kicker: 'External Agents',
|
||||
title: 'ACP Endpoints',
|
||||
desc: 'Delegate prompts to external coding agents (codex / claude-code / opencode / qwen-code) over stdio. Enable an endpoint after installing its CLI.',
|
||||
addEndpoint: 'Add Endpoint',
|
||||
builtin: 'Builtin',
|
||||
test: 'Test',
|
||||
testing: 'Testing...',
|
||||
statusUnknown: 'untested',
|
||||
empty: 'No ACP endpoints yet.',
|
||||
loadFailed: 'Failed to load ACP endpoints',
|
||||
saveFailed: 'Failed to save endpoint',
|
||||
deleteTitle: 'Confirm Delete',
|
||||
deleteConfirm: 'Delete ACP endpoint "{name}"? Builtin endpoints are protected.',
|
||||
deleteFailed: 'Failed to delete endpoint',
|
||||
toggleFailed: 'Failed to toggle endpoint',
|
||||
invalidJson: 'Invalid JSON',
|
||||
columns: {
|
||||
name: 'Endpoint',
|
||||
command: 'Command',
|
||||
status: 'Last Test',
|
||||
enabled: 'Enabled',
|
||||
actions: 'Actions',
|
||||
},
|
||||
fields: {
|
||||
name: 'Name (slug)',
|
||||
displayName: 'Display name',
|
||||
description: 'Description',
|
||||
command: 'Command',
|
||||
args: 'Args (JSON array)',
|
||||
env: 'Env (JSON object)',
|
||||
toolParseMode: 'Tool parse mode',
|
||||
enabled: 'Enabled',
|
||||
},
|
||||
modal: {
|
||||
newTitle: 'Add ACP Endpoint',
|
||||
editTitle: 'Edit ACP Endpoint',
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
kicker: 'Capability Bridge',
|
||||
title: 'MCP Connections',
|
||||
|
||||
@ -248,6 +248,7 @@ export default {
|
||||
mcpConnections: 'MCP 连接',
|
||||
toolsCatalog: '工具目录',
|
||||
activity: '活动记录',
|
||||
acpEndpoints: 'ACP Endpoints',
|
||||
settingsGroup: '设置',
|
||||
agents: '智能体',
|
||||
security: '安全',
|
||||
@ -1049,6 +1050,45 @@ export default {
|
||||
loadFailed: '加载 Token 统计失败',
|
||||
noData: '暂无 Token 使用数据',
|
||||
},
|
||||
acp: {
|
||||
kicker: '外部 Agent',
|
||||
title: 'ACP Endpoints',
|
||||
desc: '通过 stdio 委派给外部编码 Agent(codex / claude-code / opencode / qwen-code)。安装对应 CLI 后开启该入口。',
|
||||
addEndpoint: '新增 Endpoint',
|
||||
builtin: '内置',
|
||||
test: '测试',
|
||||
testing: '测试中…',
|
||||
statusUnknown: '未测试',
|
||||
empty: '暂无 ACP endpoint。',
|
||||
loadFailed: '加载 ACP endpoint 列表失败',
|
||||
saveFailed: '保存 endpoint 失败',
|
||||
deleteTitle: '确认删除',
|
||||
deleteConfirm: '确认删除 ACP endpoint "{name}"?内置 endpoint 不可删除。',
|
||||
deleteFailed: '删除 endpoint 失败',
|
||||
toggleFailed: '切换 endpoint 状态失败',
|
||||
invalidJson: 'JSON 格式错误',
|
||||
columns: {
|
||||
name: 'Endpoint',
|
||||
command: '命令',
|
||||
status: '最近一次测试',
|
||||
enabled: '启用',
|
||||
actions: '操作',
|
||||
},
|
||||
fields: {
|
||||
name: '标识 (slug)',
|
||||
displayName: '显示名',
|
||||
description: '描述',
|
||||
command: '命令',
|
||||
args: '参数 (JSON 数组)',
|
||||
env: '环境变量 (JSON 对象)',
|
||||
toolParseMode: 'Tool 解析模式',
|
||||
enabled: '启用',
|
||||
},
|
||||
modal: {
|
||||
newTitle: '新增 ACP Endpoint',
|
||||
editTitle: '编辑 ACP Endpoint',
|
||||
},
|
||||
},
|
||||
mcp: {
|
||||
kicker: '能力桥接',
|
||||
title: 'MCP 连接',
|
||||
|
||||
@ -170,6 +170,13 @@ const router = createRouter({
|
||||
component: () => import('@/views/Tools.vue'),
|
||||
meta: { title: 'Settings - Tools Catalog' },
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints (External coding agents)
|
||||
{
|
||||
path: 'acp',
|
||||
name: 'SettingsAcpEndpoints',
|
||||
component: () => import('@/views/AcpEndpoints.vue'),
|
||||
meta: { title: 'Settings - ACP Endpoints' },
|
||||
},
|
||||
{
|
||||
path: 'token-usage',
|
||||
name: 'SettingsTokenUsage',
|
||||
|
||||
384
mateclaw-ui/src/views/AcpEndpoints.vue
Normal file
384
mateclaw-ui/src/views/AcpEndpoints.vue
Normal file
@ -0,0 +1,384 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div class="page-shell">
|
||||
<div class="page-header">
|
||||
<div class="page-lead">
|
||||
<div class="page-kicker">{{ t('acp.kicker') }}</div>
|
||||
<h1 class="page-title">{{ t('acp.title') }}</h1>
|
||||
<p class="page-desc">{{ t('acp.desc') }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn-primary" @click="openCreateModal">
|
||||
+ {{ t('acp.addEndpoint') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="table-wrap">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('acp.columns.name') }}</th>
|
||||
<th>{{ t('acp.columns.command') }}</th>
|
||||
<th class="th-center">{{ t('acp.columns.status') }}</th>
|
||||
<th class="th-center">{{ t('acp.columns.enabled') }}</th>
|
||||
<th>{{ t('acp.columns.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="ep in endpoints" :key="ep.id" class="data-row">
|
||||
<td>
|
||||
<div class="endpoint-info">
|
||||
<div class="endpoint-name">
|
||||
{{ ep.displayName || ep.name }}
|
||||
<span v-if="ep.builtin" class="builtin-badge">{{ t('acp.builtin') }}</span>
|
||||
</div>
|
||||
<div class="endpoint-slug">{{ ep.name }}</div>
|
||||
<div v-if="ep.description" class="endpoint-desc">{{ ep.description }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="cell-cmd">
|
||||
<code>{{ ep.command }} {{ argsPreview(ep) }}</code>
|
||||
</td>
|
||||
<td class="th-center">
|
||||
<span class="status-badge" :class="`status-${(ep.lastStatus || 'unknown').toLowerCase()}`">
|
||||
{{ ep.lastStatus || t('acp.statusUnknown') }}
|
||||
</span>
|
||||
<div v-if="ep.lastError" class="status-error" :title="ep.lastError">
|
||||
{{ ep.lastError.slice(0, 80) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="th-center">
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" :checked="ep.enabled" @change="toggle(ep)" />
|
||||
<span class="toggle-slider"></span>
|
||||
</label>
|
||||
</td>
|
||||
<td>
|
||||
<div class="row-actions">
|
||||
<button class="btn-link" :disabled="testingId === ep.id" @click="testEndpoint(ep)">
|
||||
{{ testingId === ep.id ? t('acp.testing') : t('acp.test') }}
|
||||
</button>
|
||||
<button class="btn-link" @click="openEditModal(ep)">{{ t('common.edit') }}</button>
|
||||
<button v-if="!ep.builtin" class="btn-link danger" @click="removeEndpoint(ep)">{{ t('common.delete') }}</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="endpoints.length === 0">
|
||||
<td colspan="5" class="empty-row">
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">🔌</span>
|
||||
<p>{{ t('acp.empty') }}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Test result panel -->
|
||||
<div v-if="lastTestResult" class="test-result mc-surface-card">
|
||||
<div class="test-result-head">
|
||||
<span :class="`status-badge status-${(lastTestResult.status || '').toLowerCase()}`">
|
||||
{{ lastTestResult.status }}
|
||||
</span>
|
||||
<span class="test-result-name">{{ lastTestResult.name }}</span>
|
||||
<span v-if="lastTestResult.elapsedMs != null" class="test-result-elapsed">
|
||||
· {{ lastTestResult.elapsedMs }}ms
|
||||
</span>
|
||||
<button class="btn-link" @click="lastTestResult = null">×</button>
|
||||
</div>
|
||||
<pre class="test-result-pre">{{ JSON.stringify(lastTestResult, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2>{{ editing ? t('acp.modal.editTitle') : t('acp.modal.newTitle') }}</h2>
|
||||
<button class="modal-close" @click="closeModal">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-grid">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('acp.fields.name') }} *</label>
|
||||
<input v-model="form.name" class="form-input" placeholder="my-acp-agent" :disabled="!!editing" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('acp.fields.displayName') }}</label>
|
||||
<input v-model="form.displayName" class="form-input" placeholder="My ACP Agent" />
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">{{ t('acp.fields.description') }}</label>
|
||||
<input v-model="form.description" class="form-input" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('acp.fields.command') }} *</label>
|
||||
<input v-model="form.command" class="form-input mono" placeholder="npx" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ t('acp.fields.toolParseMode') }}</label>
|
||||
<select v-model="form.toolParseMode" class="form-input">
|
||||
<option value="call_title">call_title</option>
|
||||
<option value="call_detail">call_detail</option>
|
||||
<option value="update_detail">update_detail</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="form-label">{{ t('acp.fields.args') }}</label>
|
||||
<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>
|
||||
<div class="form-group full-width">
|
||||
<label class="toggle-inline">
|
||||
<input type="checkbox" v-model="form.enabled" />
|
||||
{{ t('acp.fields.enabled') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn-secondary" @click="closeModal">{{ t('common.cancel') }}</button>
|
||||
<button class="btn-primary" :disabled="!canSave" @click="saveEndpoint">{{ t('common.save') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { acpApi } from '@/api/index'
|
||||
|
||||
interface AcpEndpoint {
|
||||
id: number
|
||||
name: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
command: string
|
||||
argsJson?: string
|
||||
envJson?: string
|
||||
toolParseMode?: string
|
||||
builtin?: boolean
|
||||
trusted?: boolean
|
||||
enabled?: boolean
|
||||
lastStatus?: string
|
||||
lastError?: string
|
||||
lastTestedAt?: string
|
||||
stdioBufferLimitBytes?: number
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const endpoints = ref<AcpEndpoint[]>([])
|
||||
const showModal = ref(false)
|
||||
const editing = ref<AcpEndpoint | null>(null)
|
||||
const testingId = ref<number | null>(null)
|
||||
const lastTestResult = ref<any>(null)
|
||||
|
||||
const defaultForm = (): any => ({
|
||||
name: '',
|
||||
displayName: '',
|
||||
description: '',
|
||||
command: '',
|
||||
argsJson: '[]',
|
||||
envJson: '{}',
|
||||
toolParseMode: 'call_title',
|
||||
enabled: false,
|
||||
})
|
||||
const form = reactive<any>(defaultForm())
|
||||
|
||||
const canSave = computed(() => !!form.name && !!form.command)
|
||||
|
||||
onMounted(loadEndpoints)
|
||||
|
||||
async function loadEndpoints() {
|
||||
try {
|
||||
const res: any = await acpApi.list()
|
||||
endpoints.value = res?.data || []
|
||||
} catch (e: any) {
|
||||
endpoints.value = []
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.loadFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
function argsPreview(ep: AcpEndpoint): string {
|
||||
if (!ep.argsJson) return ''
|
||||
try {
|
||||
const parsed = JSON.parse(ep.argsJson)
|
||||
if (Array.isArray(parsed)) return parsed.join(' ')
|
||||
} catch { /* fall through */ }
|
||||
return ep.argsJson
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
editing.value = null
|
||||
Object.assign(form, defaultForm())
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(ep: AcpEndpoint) {
|
||||
editing.value = ep
|
||||
Object.assign(form, defaultForm(), {
|
||||
name: ep.name,
|
||||
displayName: ep.displayName || '',
|
||||
description: ep.description || '',
|
||||
command: ep.command,
|
||||
argsJson: ep.argsJson || '[]',
|
||||
envJson: ep.envJson || '{}',
|
||||
toolParseMode: ep.toolParseMode || 'call_title',
|
||||
enabled: !!ep.enabled,
|
||||
})
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function saveEndpoint() {
|
||||
// Sanity-check args/env are valid JSON before sending; the server
|
||||
// will tolerate empty strings, but we'd rather fail fast in UI.
|
||||
try {
|
||||
if (form.argsJson) JSON.parse(form.argsJson)
|
||||
if (form.envJson) JSON.parse(form.envJson)
|
||||
} catch (e: any) {
|
||||
ElMessage.error(t('acp.invalidJson') + ': ' + (e?.message || 'parse error'))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (editing.value) {
|
||||
await acpApi.update(editing.value.id, form)
|
||||
} else {
|
||||
await acpApi.create(form)
|
||||
}
|
||||
closeModal()
|
||||
await loadEndpoints()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function removeEndpoint(ep: AcpEndpoint) {
|
||||
try {
|
||||
await ElMessageBox.confirm(t('acp.deleteConfirm', { name: ep.name }),
|
||||
t('acp.deleteTitle'), { type: 'warning' })
|
||||
} catch { return }
|
||||
try {
|
||||
await acpApi.delete(ep.id)
|
||||
await loadEndpoints()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(ep: AcpEndpoint) {
|
||||
try {
|
||||
await acpApi.toggle(ep.id, !ep.enabled)
|
||||
await loadEndpoints()
|
||||
} catch (e: any) {
|
||||
ElMessage.error(typeof e === 'string' ? e : e?.message || t('acp.toggleFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function testEndpoint(ep: AcpEndpoint) {
|
||||
testingId.value = ep.id
|
||||
lastTestResult.value = null
|
||||
try {
|
||||
const res: any = await acpApi.test(ep.id)
|
||||
lastTestResult.value = res?.data || null
|
||||
await loadEndpoints()
|
||||
} catch (e: any) {
|
||||
lastTestResult.value = {
|
||||
name: ep.name,
|
||||
status: 'ERROR',
|
||||
error: typeof e === 'string' ? e : e?.message || 'unknown',
|
||||
}
|
||||
} finally {
|
||||
testingId.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-container { padding: 0; height: 100%; min-height: 0; overflow: auto; }
|
||||
.page-shell { display: flex; flex-direction: column; gap: 14px; padding: 22px; min-height: 100%; }
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.page-kicker { font-size: 11px; color: var(--mc-text-tertiary); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 700; }
|
||||
.page-title { font-size: 22px; font-weight: 700; color: var(--mc-text-primary); margin: 4px 0; }
|
||||
.page-desc { color: var(--mc-text-secondary); margin: 0; font-size: 13px; }
|
||||
.header-actions { display: flex; gap: 8px; }
|
||||
.btn-primary { padding: 8px 14px; background: var(--mc-primary); color: white; border: none; border-radius: 10px; font-size: 13px; font-weight: 600; cursor: pointer; }
|
||||
.btn-primary:hover { background: var(--mc-primary-hover); }
|
||||
.btn-primary:disabled { background: var(--mc-border); cursor: not-allowed; }
|
||||
.btn-secondary { padding: 8px 14px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); border-radius: 10px; font-size: 13px; cursor: pointer; }
|
||||
.btn-secondary:hover { background: var(--mc-bg-sunken); }
|
||||
.btn-link { background: none; border: none; color: var(--mc-primary); cursor: pointer; padding: 4px 8px; font-size: 12px; font-weight: 500; }
|
||||
.btn-link:hover { color: var(--mc-primary-hover); }
|
||||
.btn-link.danger { color: var(--mc-danger); }
|
||||
.btn-link:disabled { color: var(--mc-text-tertiary); cursor: not-allowed; }
|
||||
|
||||
.table-wrap { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border-light); border-radius: 14px; overflow: hidden; }
|
||||
.data-table { width: 100%; border-collapse: collapse; }
|
||||
.data-table th { padding: 12px 14px; text-align: left; font-size: 11px; font-weight: 700; color: var(--mc-text-secondary); text-transform: uppercase; letter-spacing: 0.06em; background: var(--mc-bg-muted); border-bottom: 1px solid var(--mc-border); }
|
||||
.data-table td { padding: 12px 14px; font-size: 13px; color: var(--mc-text-primary); border-bottom: 1px solid var(--mc-border-light); vertical-align: top; }
|
||||
.data-row:hover { background: var(--mc-bg-muted); }
|
||||
.th-center { text-align: center; }
|
||||
.endpoint-info { display: flex; flex-direction: column; gap: 2px; }
|
||||
.endpoint-name { font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.builtin-badge { padding: 1px 6px; background: rgba(34, 197, 94, 0.12); color: #16a34a; border-radius: 999px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
|
||||
.endpoint-slug { font-size: 11px; color: var(--mc-text-tertiary); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.endpoint-desc { font-size: 12px; color: var(--mc-text-secondary); margin-top: 4px; }
|
||||
.cell-cmd code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; background: var(--mc-bg-sunken); padding: 2px 6px; border-radius: 4px; color: var(--mc-text-primary); display: inline-block; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
|
||||
|
||||
.status-badge { padding: 2px 10px; border-radius: 999px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.status-ok { background: rgba(34, 197, 94, 0.12); color: #16a34a; }
|
||||
.status-error { background: var(--mc-danger-bg); color: var(--mc-danger); }
|
||||
.status-unknown { background: var(--mc-bg-sunken); color: var(--mc-text-tertiary); }
|
||||
|
||||
td .status-error { background: none; color: var(--mc-text-tertiary); font-size: 11px; padding: 4px 0 0; max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.row-actions { display: flex; gap: 4px; }
|
||||
|
||||
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; cursor: pointer; }
|
||||
.toggle-switch input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider { position: absolute; inset: 0; background: var(--mc-border); border-radius: 20px; transition: 0.2s; }
|
||||
.toggle-slider::before { content: ''; position: absolute; width: 14px; height: 14px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
|
||||
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
|
||||
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||||
|
||||
.empty-row { padding: 40px !important; }
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; gap: 8px; color: var(--mc-text-tertiary); }
|
||||
.empty-icon { font-size: 32px; }
|
||||
|
||||
.test-result { padding: 14px; }
|
||||
.test-result-head { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
|
||||
.test-result-name { font-weight: 600; }
|
||||
.test-result-elapsed { color: var(--mc-text-tertiary); font-size: 12px; }
|
||||
.test-result-pre { background: var(--mc-bg-sunken); padding: 12px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; line-height: 1.5; max-height: 360px; overflow: auto; white-space: pre-wrap; word-break: break-word; margin: 0; }
|
||||
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 20px; }
|
||||
.modal { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; width: 100%; max-width: 640px; max-height: 90vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15); }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: 18px 22px; border-bottom: 1px solid var(--mc-border-light); }
|
||||
.modal-header h2 { font-size: 17px; font-weight: 600; margin: 0; }
|
||||
.modal-close { width: 30px; height: 30px; border: none; background: none; cursor: pointer; color: var(--mc-text-tertiary); font-size: 22px; line-height: 1; border-radius: 6px; }
|
||||
.modal-close:hover { background: var(--mc-bg-sunken); }
|
||||
.modal-body { flex: 1; overflow-y: auto; padding: 18px 22px; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-group.full-width { grid-column: 1 / -1; }
|
||||
.form-label { font-size: 12px; font-weight: 600; color: var(--mc-text-secondary); }
|
||||
.form-input { padding: 8px 10px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 13px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); }
|
||||
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217, 119, 87, 0.1); }
|
||||
.form-input.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
.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); }
|
||||
</style>
|
||||
@ -169,6 +169,13 @@ const sections = computed(() => [
|
||||
label: t('nav.toolsCatalog'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
|
||||
},
|
||||
// RFC-090 Phase 7: ACP endpoints
|
||||
{
|
||||
id: 'acp',
|
||||
path: '/settings/acp',
|
||||
label: t('nav.acpEndpoints'),
|
||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>',
|
||||
},
|
||||
{
|
||||
id: 'token-usage',
|
||||
path: '/settings/token-usage',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user