ACP / MCP

This commit is contained in:
2026-06-12 11:44:19 +08:00
parent 2a9661a6ef
commit 4ea4adfd8e
47 changed files with 2558 additions and 240 deletions
+46
View File
@@ -1,5 +1,23 @@
import { request } from './client'
// AgentClientType mirrors backend acp.ClientType: decides which config-dir
// redirect env var is injected at spawn and which config form is rendered.
export type AgentClientType = 'claude_code' | 'codex' | 'gemini' | 'generic'
// McpServerSpec mirrors backend acp.McpServerSpec. Delivered to the agent via
// ACP session/new alongside the built-in ACW MCP server (per-session token).
// stdio is the ACP baseline; http/sse entries are skipped for agents that do
// not declare the matching mcpCapabilities. Name "acw" is reserved.
export interface McpServerSpec {
name: string
type: 'stdio' | 'http' | 'sse'
command?: string
args?: string[]
env?: Record<string, string>
url?: string
headers?: Record<string, string>
}
// Admin DTO from `internal/acp/dto.go: AgentKindAdminDTO`.
export interface AgentKindAdmin {
id: string
@@ -11,6 +29,8 @@ export interface AgentKindAdmin {
env_keys: string[]
enabled: boolean
tool_allowlist: string[]
client_type: AgentClientType
mcp_servers: McpServerSpec[]
created_by: string
created_at: string
updated_at: string
@@ -38,6 +58,8 @@ export interface CreateAgentKindReq {
env?: Record<string, string>
enabled?: boolean
tool_allowlist?: string[]
client_type?: AgentClientType
mcp_servers?: McpServerSpec[]
}
export interface UpdateAgentKindReq {
@@ -48,6 +70,15 @@ export interface UpdateAgentKindReq {
env?: Record<string, string>
enabled?: boolean
tool_allowlist?: string[]
client_type?: AgentClientType
mcp_servers?: McpServerSpec[]
}
// AgentConfigFile mirrors backend ConfigFileDTO (admin only, plaintext content).
export interface AgentConfigFile {
rel_path: string
content: string
updated_at: string
}
// PermissionRequest mirrors backend PermissionRequestDTO.
@@ -126,6 +157,21 @@ export const acpApi = {
remove: (id: string) =>
request<void>(`/api/v1/acp/agent-kinds/${enc(id)}`, { method: 'DELETE' }),
},
// Agent CLI 配置文件(admin only)。rel_path 含斜杠,走 body / query 而非 URL path。
configFiles: {
list: (kindId: string) =>
request<AgentConfigFile[]>(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`),
upsert: (kindId: string, relPath: string, content: string) =>
request<AgentConfigFile>(`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files`, {
method: 'PUT',
body: { rel_path: relPath, content },
}),
remove: (kindId: string, relPath: string) =>
request<void>(
`/api/v1/acp/agent-kinds/${enc(kindId)}/config-files?rel_path=${enc(relPath)}`,
{ method: 'DELETE' },
),
},
sessions: {
list: (opts: { all?: boolean; requirement_id?: string } = {}) => {
const q = new URLSearchParams()
@@ -0,0 +1,509 @@
<script setup lang="ts">
// AgentKind 配置文件管理面板(admin only,仅编辑模式可用)。
//
// 两种编辑形态:
// - 常用配置表单:针对 claude_code / codex / gemini 的规范配置文件
// (.claude/settings.json / .codex/config.toml / .gemini/settings.json),
// 只管理常用字段,保存时合并回原文件(未识别字段原样保留)。
// - 高级模式:任意 rel_path 的原始文本编辑,按扩展名做 JSON/TOML 校验。
//
// 配置在 spawn 前物化到受管 home 并通过 CLAUDE_CONFIG_DIR / CODEX_HOME /
// GEMINI_CLI_HOME 注入;agent 运行期对受管文件的改动会在下次 spawn 被 DB 版本覆盖。
import { ref, computed, onMounted, watch } from 'vue'
import { NInput, NButton, NSelect, NSpin } from 'naive-ui'
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml'
import { acpApi, type AgentConfigFile, type AgentClientType } from '@/api/acp'
const props = defineProps<{
kindId: string
clientType: AgentClientType
}>()
const files = ref<AgentConfigFile[]>([])
const loading = ref(false)
const error = ref('')
// ===== 客户端规范配置文件 =====
const canonicalPath = computed(() => {
switch (props.clientType) {
case 'claude_code':
return '.claude/settings.json'
case 'codex':
return '.codex/config.toml'
case 'gemini':
return '.gemini/settings.json'
default:
return null
}
})
interface FormState {
model: string
// claude_code
defaultMode: string
allow: string
deny: string
ask: string
// codex
approvalPolicy: string | null
sandboxMode: string | null
}
const form = ref<FormState>({
model: '',
defaultMode: '',
allow: '',
deny: '',
ask: '',
approvalPolicy: null,
sandboxMode: null,
})
const approvalPolicyOptions = [
{ label: 'untrusted', value: 'untrusted' },
{ label: 'on-request', value: 'on-request' },
{ label: 'never', value: 'never' },
]
const sandboxModeOptions = [
{ label: 'read-only', value: 'read-only' },
{ label: 'workspace-write', value: 'workspace-write' },
{ label: 'danger-full-access', value: 'danger-full-access' },
]
function findFile(relPath: string): AgentConfigFile | undefined {
return files.value.find((f) => f.rel_path === relPath)
}
function linesToArr(s: string): string[] {
return s
.split('\n')
.map((x) => x.trim())
.filter((x) => x !== '')
}
// loadForm 从规范文件解析常用字段;文件缺失或解析失败时保持空表单。
function loadForm() {
const f = canonicalPath.value ? findFile(canonicalPath.value) : undefined
form.value = {
model: '',
defaultMode: '',
allow: '',
deny: '',
ask: '',
approvalPolicy: null,
sandboxMode: null,
}
if (!f) return
try {
if (props.clientType === 'codex') {
const doc = parseToml(f.content) as Record<string, unknown>
form.value.model = typeof doc.model === 'string' ? doc.model : ''
form.value.approvalPolicy =
typeof doc.approval_policy === 'string' ? doc.approval_policy : null
form.value.sandboxMode = typeof doc.sandbox_mode === 'string' ? doc.sandbox_mode : null
} else if (props.clientType === 'claude_code') {
const doc = JSON.parse(f.content) as Record<string, unknown>
form.value.model = typeof doc.model === 'string' ? doc.model : ''
const perms = (doc.permissions ?? {}) as Record<string, unknown>
form.value.defaultMode = typeof perms.defaultMode === 'string' ? perms.defaultMode : ''
form.value.allow = Array.isArray(perms.allow) ? perms.allow.join('\n') : ''
form.value.deny = Array.isArray(perms.deny) ? perms.deny.join('\n') : ''
form.value.ask = Array.isArray(perms.ask) ? perms.ask.join('\n') : ''
} else if (props.clientType === 'gemini') {
const doc = JSON.parse(f.content) as Record<string, unknown>
const model = (doc.model ?? {}) as Record<string, unknown>
form.value.model = typeof model.name === 'string' ? model.name : ''
}
} catch {
// 文件内容非法时表单留空;用户可在高级模式修复
}
}
// saveForm 把表单字段合并回规范文件(保留未识别字段),空值删除对应 key。
async function saveForm() {
if (!canonicalPath.value) return
const existing = findFile(canonicalPath.value)
try {
if (props.clientType === 'codex') {
let doc: Record<string, unknown> = {}
if (existing) doc = parseToml(existing.content) as Record<string, unknown>
if (form.value.model) doc.model = form.value.model
else delete doc.model
if (form.value.approvalPolicy) doc.approval_policy = form.value.approvalPolicy
else delete doc.approval_policy
if (form.value.sandboxMode) doc.sandbox_mode = form.value.sandboxMode
else delete doc.sandbox_mode
await upsert(canonicalPath.value, stringifyToml(doc) + '\n')
} else {
let doc: Record<string, unknown> = {}
if (existing) doc = JSON.parse(existing.content) as Record<string, unknown>
if (props.clientType === 'claude_code') {
if (form.value.model) doc.model = form.value.model
else delete doc.model
const perms = (doc.permissions ?? {}) as Record<string, unknown>
if (form.value.defaultMode) perms.defaultMode = form.value.defaultMode
else delete perms.defaultMode
for (const key of ['allow', 'deny', 'ask'] as const) {
const arr = linesToArr(form.value[key])
if (arr.length) perms[key] = arr
else delete perms[key]
}
if (Object.keys(perms).length) doc.permissions = perms
else delete doc.permissions
} else if (props.clientType === 'gemini') {
const model = (doc.model ?? {}) as Record<string, unknown>
if (form.value.model) model.name = form.value.model
else delete model.name
if (Object.keys(model).length) doc.model = model
else delete doc.model
}
await upsert(canonicalPath.value, JSON.stringify(doc, null, 2) + '\n')
}
} catch (e) {
error.value = `保存失败:${e instanceof Error ? e.message : '现有文件内容无法解析,请在高级模式修复'}`
}
}
// ===== 高级模式:原始文件编辑 =====
const editingPath = ref<string | null>(null)
const editingContent = ref('')
const editingIsNew = ref(false)
const newRelPath = ref('')
const syntaxError = ref('')
// 按 client_type 提供的新建模板
const templates = computed<{ label: string; rel_path: string; content: string }[]>(() => {
switch (props.clientType) {
case 'claude_code':
return [
{
label: '.claude/settings.json',
rel_path: '.claude/settings.json',
content: '{\n "permissions": {\n "allow": [],\n "deny": []\n }\n}\n',
},
]
case 'codex':
return [
{
label: '.codex/config.toml',
rel_path: '.codex/config.toml',
content: 'approval_policy = "on-request"\nsandbox_mode = "workspace-write"\n',
},
{
label: '.codex/auth.json',
rel_path: '.codex/auth.json',
content: '{\n "OPENAI_API_KEY": ""\n}\n',
},
]
case 'gemini':
return [
{
label: '.gemini/settings.json',
rel_path: '.gemini/settings.json',
content: '{\n "mcpServers": {}\n}\n',
},
]
default:
return []
}
})
function validateSyntax(relPath: string, content: string): string {
const lower = relPath.toLowerCase()
try {
if (lower.endsWith('.json')) JSON.parse(content)
else if (lower.endsWith('.toml')) parseToml(content)
} catch (e) {
return e instanceof Error ? e.message : 'syntax error'
}
return ''
}
watch([editingContent, newRelPath], () => {
const p = editingIsNew.value ? newRelPath.value : (editingPath.value ?? '')
syntaxError.value = p ? validateSyntax(p, editingContent.value) : ''
})
function startEdit(f: AgentConfigFile) {
editingIsNew.value = false
editingPath.value = f.rel_path
editingContent.value = f.content
syntaxError.value = ''
}
function startNew(template?: { rel_path: string; content: string }) {
editingIsNew.value = true
editingPath.value = null
newRelPath.value = template?.rel_path ?? ''
editingContent.value = template?.content ?? ''
syntaxError.value = ''
}
function cancelEdit() {
editingPath.value = null
editingIsNew.value = false
newRelPath.value = ''
editingContent.value = ''
syntaxError.value = ''
}
async function saveEdit() {
const relPath = editingIsNew.value ? newRelPath.value.trim() : editingPath.value
if (!relPath) {
error.value = '请填写文件路径'
return
}
if (syntaxError.value) return
await upsert(relPath, editingContent.value)
cancelEdit()
}
async function upsert(relPath: string, content: string) {
error.value = ''
try {
await acpApi.configFiles.upsert(props.kindId, relPath, content)
await refresh()
} catch (e) {
error.value = `保存失败:${e instanceof Error ? e.message : 'unknown'}`
}
}
async function removeFile(relPath: string) {
if (!confirm(`删除配置文件 ${relPath}`)) return
error.value = ''
try {
await acpApi.configFiles.remove(props.kindId, relPath)
await refresh()
} catch (e) {
error.value = `删除失败:${e instanceof Error ? e.message : 'unknown'}`
}
}
async function refresh() {
loading.value = true
try {
files.value = await acpApi.configFiles.list(props.kindId)
loadForm()
} catch (e) {
error.value = `加载失败:${e instanceof Error ? e.message : 'unknown'}`
} finally {
loading.value = false
}
}
onMounted(refresh)
watch(() => props.clientType, loadForm)
</script>
<template>
<div class="space-y-4">
<div>
<h2 class="text-lg font-semibold">客户端配置文件</h2>
<p class="text-xs text-gray-500 mt-1">
配置存数据库session 启动前物化到受管目录并通过
<code v-if="clientType === 'claude_code'">CLAUDE_CONFIG_DIR</code>
<code v-else-if="clientType === 'codex'">CODEX_HOME</code>
<code v-else-if="clientType === 'gemini'">GEMINI_CLI_HOME</code>
<code v-else>HOME</code>
指向该目录agent 运行期对这些文件的修改会在下次启动时被覆盖
</p>
</div>
<p
v-if="error"
class="text-sm text-red-600"
>
{{ error }}
</p>
<NSpin :show="loading">
<!-- 常用配置表单已知客户端类型 -->
<div
v-if="canonicalPath"
class="border rounded p-4 space-y-3"
>
<div class="text-sm font-medium">
常用配置
<span class="text-xs text-gray-500 font-normal">写入 {{ canonicalPath }}其余字段保留</span>
</div>
<div>
<label class="block text-sm mb-1">Model</label>
<NInput
v-model:value="form.model"
:placeholder="
clientType === 'codex'
? 'gpt-5.5'
: clientType === 'gemini'
? 'gemini-2.5-pro'
: 'claude-sonnet-4-6'
"
/>
</div>
<template v-if="clientType === 'claude_code'">
<div>
<label class="block text-sm mb-1">permissions.defaultMode</label>
<NInput
v-model:value="form.defaultMode"
placeholder="acceptEdits"
/>
</div>
<div class="grid grid-cols-3 gap-2">
<div>
<label class="block text-sm mb-1">allow每行一条</label>
<NInput
v-model:value="form.allow"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Bash(git status)"
/>
</div>
<div>
<label class="block text-sm mb-1">deny每行一条</label>
<NInput
v-model:value="form.deny"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Read(~/.ssh/**)"
/>
</div>
<div>
<label class="block text-sm mb-1">ask每行一条</label>
<NInput
v-model:value="form.ask"
type="textarea"
:autosize="{ minRows: 3, maxRows: 8 }"
placeholder="Write(src/**)"
/>
</div>
</div>
</template>
<template v-if="clientType === 'codex'">
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-sm mb-1">approval_policy</label>
<NSelect
v-model:value="form.approvalPolicy"
:options="approvalPolicyOptions"
clearable
/>
</div>
<div>
<label class="block text-sm mb-1">sandbox_mode</label>
<NSelect
v-model:value="form.sandboxMode"
:options="sandboxModeOptions"
clearable
/>
</div>
</div>
</template>
<NButton
size="small"
type="primary"
@click="saveForm"
>
保存常用配置
</NButton>
</div>
<!-- 高级模式文件列表 + 原始编辑 -->
<div class="border rounded p-4 space-y-3 mt-4">
<div class="text-sm font-medium">全部配置文件高级模式</div>
<div
v-if="files.length === 0"
class="text-sm text-gray-500"
>
暂无配置文件
</div>
<div
v-for="f in files"
:key="f.rel_path"
class="flex items-center justify-between text-sm border-b pb-1"
>
<code>{{ f.rel_path }}</code>
<div class="flex gap-2 items-center">
<span class="text-xs text-gray-400">{{ f.updated_at }}</span>
<NButton
size="tiny"
@click="startEdit(f)"
>
编辑
</NButton>
<NButton
size="tiny"
quaternary
type="error"
@click="removeFile(f.rel_path)"
>
删除
</NButton>
</div>
</div>
<div class="flex gap-2 flex-wrap">
<NButton
size="small"
dashed
@click="startNew()"
>
+ 新建文件
</NButton>
<NButton
v-for="t in templates.filter((x) => !files.some((f) => f.rel_path === x.rel_path))"
:key="t.rel_path"
size="small"
dashed
@click="startNew(t)"
>
+ {{ t.label }}
</NButton>
</div>
<div
v-if="editingIsNew || editingPath !== null"
class="space-y-2 pt-2"
>
<div v-if="editingIsNew">
<label class="block text-sm mb-1">文件路径相对受管 home .claude/settings.json</label>
<NInput
v-model:value="newRelPath"
placeholder=".claude/settings.json"
/>
</div>
<div v-else>
<code class="text-sm">{{ editingPath }}</code>
</div>
<NInput
v-model:value="editingContent"
type="textarea"
class="font-mono"
:autosize="{ minRows: 8, maxRows: 24 }"
/>
<p
v-if="syntaxError"
class="text-xs text-red-600"
>
语法错误{{ syntaxError }}
</p>
<div class="flex gap-2">
<NButton
size="small"
type="primary"
:disabled="!!syntaxError"
@click="saveEdit"
>
保存
</NButton>
<NButton
size="small"
@click="cancelEdit"
>
取消
</NButton>
</div>
</div>
</div>
</NSpin>
</div>
</template>
+196 -1
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref, watch, computed } from 'vue'
import { NInput, NButton, NCheckbox } from 'naive-ui'
import { NInput, NButton, NCheckbox, NSelect } from 'naive-ui'
import type { AgentClientType, McpServerSpec } from '@/api/acp'
const props = defineProps<{
modelValue: {
@@ -12,11 +13,20 @@ const props = defineProps<{
env: Record<string, string>
enabled: boolean
tool_allowlist: string[]
client_type: AgentClientType
mcp_servers: McpServerSpec[]
}
isEdit: boolean
existingEnvKeys?: string[]
}>()
const clientTypeOptions: { label: string; value: AgentClientType }[] = [
{ label: 'Claude Code', value: 'claude_code' },
{ label: 'Codex', value: 'codex' },
{ label: 'Gemini CLI', value: 'gemini' },
{ label: '通用(仅注入 HOME)', value: 'generic' },
]
const emit = defineEmits<{
(e: 'update:modelValue', value: typeof props.modelValue): void
(e: 'submit'): void
@@ -72,6 +82,109 @@ function addEnv() {
function removeEnv(idx: number) {
envEntries.value.splice(idx, 1)
}
// ===== 第三方 MCP servers =====
// session/new 握手时随自家 ACW MCP 一并下发。stdio 是 ACP 基线;
// http/sse 在 agent 未声明对应能力时会被后端逐条跳过。
const mcpTypeOptions = [
{ label: 'stdio', value: 'stdio' },
{ label: 'http', value: 'http' },
{ label: 'sse', value: 'sse' },
]
interface McpRow {
name: string
type: 'stdio' | 'http' | 'sse'
command: string
argsText: string
envText: string
url: string
headersText: string
}
function kvToText(m?: Record<string, string>): string {
return Object.entries(m ?? {})
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function parseKV(s: string): Record<string, string> | undefined {
const m: Record<string, string> = {}
for (const line of s.split('\n')) {
const t = line.trim()
if (!t) continue
const i = t.indexOf('=')
if (i <= 0) continue
m[t.slice(0, i).trim()] = t.slice(i + 1)
}
return Object.keys(m).length ? m : undefined
}
function specsToRows(specs: McpServerSpec[]): McpRow[] {
return specs.map((s) => ({
name: s.name,
type: s.type,
command: s.command ?? '',
argsText: (s.args ?? []).join('\n'),
envText: kvToText(s.env),
url: s.url ?? '',
headersText: kvToText(s.headers),
}))
}
function rowsToSpecs(rows: McpRow[]): McpServerSpec[] {
return rows.map((r) => {
const spec: McpServerSpec = { name: r.name.trim(), type: r.type }
if (r.type === 'stdio') {
spec.command = r.command.trim()
const args = r.argsText.split('\n').filter((x) => x.trim() !== '')
if (args.length) spec.args = args
const env = parseKV(r.envText)
if (env) spec.env = env
} else {
spec.url = r.url.trim()
const headers = parseKV(r.headersText)
if (headers) spec.headers = headers
}
return spec
})
}
const mcpRows = ref<McpRow[]>(specsToRows(local.value.mcp_servers ?? []))
watch(
mcpRows,
(rows) => {
local.value.mcp_servers = rowsToSpecs(rows)
},
{ deep: true },
)
// 编辑模式下 modelValue 异步加载完成后回填行(避免与上面的 watch 互相触发)
watch(
() => props.modelValue.mcp_servers,
(v) => {
if (JSON.stringify(v ?? []) !== JSON.stringify(rowsToSpecs(mcpRows.value))) {
mcpRows.value = specsToRows(v ?? [])
}
},
)
function addMcpServer() {
mcpRows.value.push({
name: '',
type: 'http',
command: '',
argsText: '',
envText: '',
url: '',
headersText: '',
})
}
function removeMcpServer(idx: number) {
mcpRows.value.splice(idx, 1)
}
</script>
<template>
@@ -108,6 +221,16 @@ function removeEnv(idx: number) {
:autosize="{ minRows: 2, maxRows: 6 }"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">客户端类型</label>
<NSelect
v-model:value="local.client_type"
:options="clientTypeOptions"
/>
<p class="text-xs text-gray-500 mt-1">
决定 session 启动时注入的配置目录变量与下方配置文件表单
</p>
</div>
<div>
<label class="block text-sm font-medium mb-1">Binary Path</label>
<NInput
@@ -176,6 +299,78 @@ function removeEnv(idx: number) {
placeholder="fs/read_text_file&#10;safe.tool"
/>
</div>
<div>
<label class="block text-sm font-medium mb-1">
第三方 MCP Serverssession 启动时随平台 MCP 一并下发名称 acw 为平台保留
</label>
<div
v-for="(m, idx) in mcpRows"
:key="idx"
class="border rounded p-3 mb-2 space-y-2"
>
<div class="flex gap-2">
<NInput
v-model:value="m.name"
placeholder="名称(如 context7)"
class="flex-1"
/>
<NSelect
v-model:value="m.type"
:options="mcpTypeOptions"
class="w-28"
/>
<NButton
quaternary
@click="removeMcpServer(idx)"
>
×
</NButton>
</div>
<template v-if="m.type === 'stdio'">
<NInput
v-model:value="m.command"
placeholder="command(如 npx)"
/>
<NInput
v-model:value="m.argsText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="args(每行一个,如 -y&#10;@upstash/context7-mcp)"
/>
<NInput
v-model:value="m.envText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="env(每行 KEY=VALUE)"
/>
</template>
<template v-else>
<NInput
v-model:value="m.url"
placeholder="URL(如 https://mcp.example.com/mcp)"
/>
<NInput
v-model:value="m.headersText"
type="textarea"
:autosize="{ minRows: 1, maxRows: 5 }"
placeholder="headers(每行 KEY=VALUE,如 Authorization=Bearer xxx)"
/>
<p
v-if="m.type === 'sse'"
class="text-xs text-gray-500"
>
注意codex 不支持 sse 类型会在启动时被跳过
</p>
</template>
</div>
<NButton
size="small"
dashed
@click="addMcpServer"
>
+ 添加 MCP Server
</NButton>
</div>
<div class="flex items-center gap-2">
<NCheckbox v-model:checked="local.enabled">
Enabled
+26 -1
View File
@@ -3,8 +3,9 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppShell from '@/layouts/AppShell.vue'
import AgentKindForm from '@/components/acp/AgentKindForm.vue'
import AgentConfigFilesPanel from '@/components/acp/AgentConfigFilesPanel.vue'
import { useAcpStore } from '@/stores/acp'
import type { UpdateAgentKindReq } from '@/api/acp'
import type { AgentClientType, McpServerSpec, UpdateAgentKindReq } from '@/api/acp'
const route = useRoute()
const router = useRouter()
@@ -22,6 +23,8 @@ const form = ref({
env: {} as Record<string, string>,
enabled: true,
tool_allowlist: [] as string[],
client_type: 'generic' as AgentClientType,
mcp_servers: [] as McpServerSpec[],
})
const existingEnvKeys = ref<string[]>([])
@@ -37,6 +40,8 @@ onMounted(async () => {
env: {}, // Edit mode: do not prefill values; user must replace entirely if they want to change.
enabled: k.enabled,
tool_allowlist: [...(k.tool_allowlist ?? [])],
client_type: k.client_type ?? 'generic',
mcp_servers: [...(k.mcp_servers ?? [])],
}
existingEnvKeys.value = [...k.env_keys]
}
@@ -52,6 +57,9 @@ async function submit() {
args: form.value.args,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
client_type: form.value.client_type,
// mcp_servers 与 env 不同:编辑页有完整回显,整体替换语义安全。
mcp_servers: form.value.mcp_servers,
}
// Only send env if user actually entered some values; an empty map would
// wipe existing env on the backend (per service patch semantics).
@@ -67,6 +75,8 @@ async function submit() {
env: form.value.env,
enabled: form.value.enabled,
tool_allowlist: form.value.tool_allowlist,
client_type: form.value.client_type,
mcp_servers: form.value.mcp_servers,
})
}
router.push('/admin/acp/agent-kinds')
@@ -89,6 +99,21 @@ async function submit() {
@submit="submit"
@cancel="router.push('/admin/acp/agent-kinds')"
/>
<div
v-if="isEdit && id"
class="mt-8"
>
<AgentConfigFilesPanel
:kind-id="id"
:client-type="form.client_type"
/>
</div>
<p
v-else
class="text-xs text-gray-500 mt-6"
>
保存后可在编辑页配置该客户端的配置文件settings.json / config.toml
</p>
</div>
</AppShell>
</template>
@@ -112,7 +112,7 @@ async function refresh() {
} else {
commits.value = await workspacesApi.logOnWorktree(target.value)
}
} catch (e: unknown) {
} catch {
commits.value = []
} finally {
loading.value = false