You've already forked agentic-coding-workflow
ACP / MCP
This commit is contained in:
@@ -23,6 +23,7 @@
|
||||
"markdown-it": "^14.1.1",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
"smol-toml": "^1.6.1",
|
||||
"vue": "^3.5.33",
|
||||
"vue-router": "^5.0.6"
|
||||
},
|
||||
|
||||
Generated
+9
@@ -20,6 +20,9 @@ importers:
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@6.0.3)(vue@3.5.33(typescript@6.0.3))
|
||||
smol-toml:
|
||||
specifier: ^1.6.1
|
||||
version: 1.6.1
|
||||
vue:
|
||||
specifier: ^3.5.33
|
||||
version: 3.5.33(typescript@6.0.3)
|
||||
@@ -1364,6 +1367,10 @@ packages:
|
||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
smol-toml@1.6.1:
|
||||
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -2905,6 +2912,8 @@ snapshots:
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
||||
smol-toml@1.6.1: {}
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 safe.tool"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">
|
||||
第三方 MCP Servers(session 启动时随平台 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 @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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user