feat: add safe agent writeback flow
This commit is contained in:
@@ -23,14 +23,14 @@ const activeComponent = computed(() => tabs.find((tab) => tab.id === activeTab.v
|
||||
<div class="app-shell">
|
||||
<header class="workspace-header">
|
||||
<div>
|
||||
<p class="eyebrow">本地只读工作台</p>
|
||||
<p class="eyebrow">本地安全工作台</p>
|
||||
<h1>Codex 智能体管理台</h1>
|
||||
<p class="lede">前端按视图连接后端只读接口,显示真实数据、加载中、连接失败和空数据状态。</p>
|
||||
<p class="lede">前端按视图连接后端接口,显示真实数据、加载中、连接失败、空数据和单文件写回状态。</p>
|
||||
</div>
|
||||
<div class="connection-card" aria-label="连接状态">
|
||||
<StatusBadge label="只读接口" status="complete" confidence="中" source="计划文件" />
|
||||
<strong>按需读取后端数据</strong>
|
||||
<span>项目、运行线程、工作流和智能体视图只调用只读端点;失败时显示“示例/等待连接”标注。</span>
|
||||
<StatusBadge label="安全接口" status="complete" confidence="中" source="计划文件" />
|
||||
<strong>按需读取和确认写回</strong>
|
||||
<span>项目、运行线程和工作流保持只读;智能体草稿仅在校验、备份和确认后单文件写回。</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -56,4 +56,18 @@ export const apiClient = {
|
||||
getWorkflowEvents() {
|
||||
return requestJSON('/api/workflow/events')
|
||||
},
|
||||
validateAgentDraft(id, content) {
|
||||
return requestJSON(`/api/agents/${encodeURIComponent(id)}/validate`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content }),
|
||||
})
|
||||
},
|
||||
writeAgentDraft(id, content, expectedHash) {
|
||||
return requestJSON(`/api/agents/${encodeURIComponent(id)}/write`, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, expectedHash }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
33
web/src/api/client.test.mjs
Normal file
33
web/src/api/client.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { apiClient } from './client.js'
|
||||
|
||||
test('api client keeps readonly APIs on GET and uses POST only for validate/write', async () => {
|
||||
const calls = []
|
||||
globalThis.fetch = async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method ?? 'GET', body: options.body ?? '' })
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
await apiClient.getAgents()
|
||||
await apiClient.getProjects()
|
||||
await apiClient.getRuntimeThreads()
|
||||
await apiClient.getWorkflowEvents()
|
||||
await apiClient.validateAgentDraft('backend', 'name = "新名称"\n')
|
||||
await apiClient.writeAgentDraft('backend', 'name = "新名称"\n', 'abc123')
|
||||
|
||||
assert.deepEqual(
|
||||
calls.map((call) => [call.path, call.method]),
|
||||
[
|
||||
['/api/agents', 'GET'],
|
||||
['/api/projects', 'GET'],
|
||||
['/api/runtime/threads', 'GET'],
|
||||
['/api/workflow/events', 'GET'],
|
||||
['/api/agents/backend/validate', 'POST'],
|
||||
['/api/agents/backend/write', 'POST'],
|
||||
],
|
||||
)
|
||||
assert.equal(calls[4].body, JSON.stringify({ content: 'name = "新名称"\n' }))
|
||||
assert.equal(calls[5].body, JSON.stringify({ content: 'name = "新名称"\n', expectedHash: 'abc123' }))
|
||||
})
|
||||
@@ -247,10 +247,64 @@ export function normalizeWorkflow(payload = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeValidationResult(result = {}) {
|
||||
const valid = result.valid === true
|
||||
const errors = Array.isArray(result.errors) ? result.errors.filter(Boolean) : []
|
||||
return {
|
||||
valid,
|
||||
errors,
|
||||
diff: result.diff || '',
|
||||
targetPath: result.targetPath || '',
|
||||
currentHash: result.currentHash || '',
|
||||
fieldChanges: Array.isArray(result.fieldChanges) ? result.fieldChanges : [],
|
||||
statusLabel: valid ? 'TOML 有效' : 'TOML 无效',
|
||||
errorText: errors.join(';') || (valid ? '' : 'TOML 校验失败'),
|
||||
steps: valid ? ['草稿', '已校验'] : ['草稿'],
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWritebackResult(result = {}) {
|
||||
return {
|
||||
status: result.status || 'unknown',
|
||||
statusLabel: result.status === 'written' ? '已写回' : '未知',
|
||||
targetPath: result.targetPath || '',
|
||||
backupPath: result.backupPath || '',
|
||||
currentHash: result.currentHash || '',
|
||||
steps: result.status === 'written' ? ['草稿', '已校验', '已备份', '已写回'] : ['草稿'],
|
||||
summary: `目标路径:${result.targetPath || '未返回'};备份路径:${result.backupPath || '未返回'}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeDraftWriteback({ validation = null, writeback = null } = {}) {
|
||||
const normalizedValidation = validation
|
||||
? (Object.hasOwn(validation, 'statusLabel') ? validation : normalizeValidationResult(validation))
|
||||
: null
|
||||
const normalizedWriteback = writeback
|
||||
? (Object.hasOwn(writeback, 'statusLabel') ? writeback : normalizeWritebackResult(writeback))
|
||||
: null
|
||||
const canWrite = Boolean(normalizedValidation?.valid && normalizedValidation.currentHash && !normalizedWriteback)
|
||||
return {
|
||||
canWrite,
|
||||
validation: normalizedValidation,
|
||||
writeback: normalizedWriteback,
|
||||
steps: normalizedWriteback?.steps ?? normalizedValidation?.steps ?? ['草稿'],
|
||||
writeDisabledReason: canWrite
|
||||
? ''
|
||||
: normalizedValidation && !normalizedValidation.valid
|
||||
? 'TOML 无效,不能写回'
|
||||
: normalizedWriteback
|
||||
? '已写回'
|
||||
: '请先校验 TOML',
|
||||
}
|
||||
}
|
||||
|
||||
function synthesizeAgentPreview(agent, { isInvalid, description }) {
|
||||
if (isInvalid) {
|
||||
return `# TOML 无效,仅只读展示解析错误\n# ${description}`
|
||||
}
|
||||
if (agent.content) {
|
||||
return agent.content
|
||||
}
|
||||
|
||||
const lines = [
|
||||
'# 接口未返回原始 TOML;以下为只读展示的已解析字段',
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
formatSourceKind,
|
||||
formatStatus,
|
||||
normalizeAgent,
|
||||
normalizeDraftWriteback,
|
||||
normalizeProject,
|
||||
normalizeRuntime,
|
||||
normalizeValidationResult,
|
||||
normalizeWritebackResult,
|
||||
normalizeWorkflow,
|
||||
} from './normalizers.js'
|
||||
import { settings } from '../data.js'
|
||||
@@ -164,3 +167,32 @@ test('keeps workflow phases with unknown backend status visible', () => {
|
||||
assert.equal(workflow.phases[0].name, '阶段 5')
|
||||
assert.equal(workflow.phases[0].label, '未知')
|
||||
})
|
||||
|
||||
test('normalizes writeback response with Chinese steps and paths', () => {
|
||||
const result = normalizeWritebackResult({
|
||||
status: 'written',
|
||||
targetPath: '/tmp/codex/agents/backend.toml',
|
||||
backupPath: '/tmp/codex/agents/backend.toml.bak-20260525',
|
||||
})
|
||||
|
||||
assert.deepEqual(result.steps, ['草稿', '已校验', '已备份', '已写回'])
|
||||
assert.equal(result.statusLabel, '已写回')
|
||||
assert.equal(result.targetPath, '/tmp/codex/agents/backend.toml')
|
||||
assert.equal(result.backupPath, '/tmp/codex/agents/backend.toml.bak-20260525')
|
||||
assert.match(result.summary, /目标路径/)
|
||||
assert.match(result.summary, /备份路径/)
|
||||
})
|
||||
|
||||
test('invalid validation disables writeback state', () => {
|
||||
const validation = normalizeValidationResult({
|
||||
valid: false,
|
||||
errors: ['第 1 行不是有效的键值字段'],
|
||||
targetPath: '/tmp/codex/agents/backend.toml',
|
||||
})
|
||||
const draft = normalizeDraftWriteback({ validation })
|
||||
|
||||
assert.equal(validation.statusLabel, 'TOML 无效')
|
||||
assert.equal(draft.canWrite, false)
|
||||
assert.equal(draft.writeDisabledReason, 'TOML 无效,不能写回')
|
||||
assert.deepEqual(draft.steps, ['草稿'])
|
||||
})
|
||||
|
||||
@@ -158,6 +158,6 @@ export const settings = [
|
||||
{ name: 'Codex 主目录', value: '/Users/yoilun/.codex', detail: '由后端配置提供', enabled: true },
|
||||
{ name: 'SQLite 状态读取', value: '只读 mode=ro&immutable=1', detail: '通过只读接口展示', enabled: true },
|
||||
{ name: '本机进程辅助判断', value: '等待后端聚合', detail: '只显示来源与置信度,不伪装确定状态', enabled: true },
|
||||
{ name: '写回能力', value: '阶段 6 才启用', detail: '当前没有保存、写入或批量写回按钮', enabled: false },
|
||||
{ name: '写回能力', value: '单文件确认写回', detail: '只允许校验后创建备份并写回一个智能体 TOML', enabled: true },
|
||||
{ name: '敏感文件黑名单', value: 'auth.json', detail: '前端只展示规则摘要,不读取内容', enabled: true },
|
||||
]
|
||||
|
||||
@@ -650,6 +650,60 @@ button {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.action-tabs button {
|
||||
flex: 0 0 auto;
|
||||
padding: 9px 12px;
|
||||
color: var(--green);
|
||||
background: var(--panel-muted);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.action-tabs button:not(:disabled):hover {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.action-tabs button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.draft-workspace {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.draft-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.draft-editor span,
|
||||
.draft-hint {
|
||||
color: var(--muted);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.draft-editor textarea {
|
||||
width: 100%;
|
||||
min-height: 320px;
|
||||
padding: 14px;
|
||||
color: #25322d;
|
||||
background: #ebe4d8;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
font-family: "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.draft-hint {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.readonly-code {
|
||||
overflow: auto;
|
||||
padding: 16px;
|
||||
|
||||
@@ -1,21 +1,54 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import StatusBadge from '../components/StatusBadge.vue'
|
||||
import WritebackSteps from '../components/WritebackSteps.vue'
|
||||
import { apiClient } from '../api/client'
|
||||
import { normalizeAgents } from '../api/normalizers'
|
||||
import {
|
||||
normalizeAgents,
|
||||
normalizeDraftWriteback,
|
||||
normalizeValidationResult,
|
||||
normalizeWritebackResult,
|
||||
} from '../api/normalizers'
|
||||
import { agents as sampleAgents } from '../data'
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const agentState = ref(normalizeAgents())
|
||||
const selectedId = ref('')
|
||||
const draftContent = ref('')
|
||||
const draftError = ref('')
|
||||
const validation = ref(null)
|
||||
const writeback = ref(null)
|
||||
const validating = ref(false)
|
||||
const writing = ref(false)
|
||||
const showDiff = ref(false)
|
||||
const validatedContent = ref('')
|
||||
const displayAgents = computed(() => (error.value ? sampleAgents.map((agent) => ({ ...agent, name: `示例:${agent.name}`, statusLabel: agent.status })) : agentState.value.agents))
|
||||
const selectedAgent = computed(() => displayAgents.value.find((agent) => agent.id === selectedId.value) ?? displayAgents.value[0])
|
||||
const draftFlow = computed(() => normalizeDraftWriteback({ validation: validation.value, writeback: writeback.value }))
|
||||
const canUseWriteback = computed(() => Boolean(selectedAgent.value && !error.value && selectedAgent.value.parseStatus !== 'invalid'))
|
||||
|
||||
watch(displayAgents, (items) => {
|
||||
selectedId.value = items[0]?.id ?? ''
|
||||
})
|
||||
|
||||
watch(selectedAgent, (agent) => {
|
||||
draftContent.value = agent?.toml ?? ''
|
||||
validation.value = null
|
||||
writeback.value = null
|
||||
draftError.value = ''
|
||||
showDiff.value = false
|
||||
validatedContent.value = ''
|
||||
})
|
||||
|
||||
watch(draftContent, (content) => {
|
||||
if (!validation.value || content === validatedContent.value) return
|
||||
validation.value = null
|
||||
writeback.value = null
|
||||
showDiff.value = false
|
||||
draftError.value = '草稿已修改,请重新校验 TOML'
|
||||
})
|
||||
|
||||
onMounted(loadAgents)
|
||||
|
||||
async function loadAgents() {
|
||||
@@ -29,6 +62,47 @@ async function loadAgents() {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function validateDraft() {
|
||||
if (!selectedAgent.value) return
|
||||
validating.value = true
|
||||
draftError.value = ''
|
||||
writeback.value = null
|
||||
try {
|
||||
validation.value = normalizeValidationResult(
|
||||
await apiClient.validateAgentDraft(selectedAgent.value.id, draftContent.value),
|
||||
)
|
||||
validatedContent.value = draftContent.value
|
||||
showDiff.value = validation.value.valid
|
||||
} catch (err) {
|
||||
validation.value = null
|
||||
draftError.value = err?.message || '校验请求失败'
|
||||
} finally {
|
||||
validating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDiff() {
|
||||
showDiff.value = !showDiff.value
|
||||
}
|
||||
|
||||
async function writeDraft() {
|
||||
if (!selectedAgent.value || !validation.value?.currentHash) return
|
||||
if (!window.confirm(`确认创建备份并写回 ${selectedAgent.value.fileName}?`)) {
|
||||
return
|
||||
}
|
||||
writing.value = true
|
||||
draftError.value = ''
|
||||
try {
|
||||
writeback.value = normalizeWritebackResult(
|
||||
await apiClient.writeAgentDraft(selectedAgent.value.id, draftContent.value, validation.value.currentHash),
|
||||
)
|
||||
} catch (err) {
|
||||
draftError.value = err?.message || '写回请求失败'
|
||||
} finally {
|
||||
writing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,20 +178,65 @@ async function loadAgents() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="subtabs" aria-label="智能体信息子标签">
|
||||
<span class="active">预览</span>
|
||||
<span>TOML</span>
|
||||
<span>差异</span>
|
||||
<span>备份</span>
|
||||
<div class="subtabs action-tabs" aria-label="智能体草稿操作">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!canUseWriteback || validating"
|
||||
@click="validateDraft"
|
||||
>
|
||||
{{ validating ? '校验中' : '校验 TOML' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!validation"
|
||||
@click="toggleDiff"
|
||||
>
|
||||
查看差异
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!draftFlow.canWrite || writing"
|
||||
@click="writeDraft"
|
||||
>
|
||||
{{ writing ? '写回中' : '创建备份并写回' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedAgent" class="readonly-code">
|
||||
<pre>{{ selectedAgent.toml }}</pre>
|
||||
<div v-if="selectedAgent" class="draft-workspace">
|
||||
<WritebackSteps :active-steps="draftFlow.steps" />
|
||||
<label class="draft-editor">
|
||||
<span>草稿内容</span>
|
||||
<textarea
|
||||
v-model="draftContent"
|
||||
:readonly="!canUseWriteback"
|
||||
rows="14"
|
||||
aria-label="智能体 TOML 草稿内容"
|
||||
></textarea>
|
||||
</label>
|
||||
<div v-if="draftError" class="empty-state compact error-state">
|
||||
<strong>操作失败</strong>
|
||||
<p>{{ draftError }}</p>
|
||||
</div>
|
||||
<div v-if="validation" class="empty-state compact" :class="{ 'error-state': !validation.valid }">
|
||||
<strong>{{ validation.statusLabel }}</strong>
|
||||
<p v-if="validation.valid">
|
||||
目标路径:{{ validation.targetPath }};字段变更:{{ validation.fieldChanges.length ? validation.fieldChanges.map((item) => item.field).join('、') : '无' }}
|
||||
</p>
|
||||
<p v-else>{{ validation.errorText }}</p>
|
||||
</div>
|
||||
<div v-if="validation && showDiff" class="readonly-code">
|
||||
<pre>{{ validation.diff || '无差异' }}</pre>
|
||||
</div>
|
||||
<div v-if="writeback" class="empty-state compact">
|
||||
<strong>{{ writeback.statusLabel }}</strong>
|
||||
<p>{{ writeback.summary }}</p>
|
||||
</div>
|
||||
<p v-if="!draftFlow.canWrite && !writeback" class="draft-hint">{{ draftFlow.writeDisabledReason }}</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-state compact">
|
||||
<strong>当前阶段没有保存入口</strong>
|
||||
<p>这里只读展示接口返回的智能体字段。真实草稿、校验、差异和写回会在阶段 6 实现。</p>
|
||||
<strong>单文件安全写回</strong>
|
||||
<p>写回前会重新校验 TOML、比较校验时的文件 hash,并先创建备份。校验失败或文件已变化时不会写回。</p>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
@@ -12,7 +12,7 @@ import { drafts } from '../data'
|
||||
<p class="eyebrow">草稿</p>
|
||||
<h2>未写回变更</h2>
|
||||
</div>
|
||||
<span class="read-only-chip">写回未启用</span>
|
||||
<span class="read-only-chip">智能体视图写回</span>
|
||||
</div>
|
||||
|
||||
<div class="draft-list">
|
||||
@@ -36,12 +36,12 @@ import { drafts } from '../data'
|
||||
|
||||
<aside class="panel draft-side">
|
||||
<div class="panel-heading">
|
||||
<p class="eyebrow">空状态</p>
|
||||
<h2>等待阶段 6 写回流程</h2>
|
||||
<p class="eyebrow">入口</p>
|
||||
<h2>到智能体视图编辑草稿</h2>
|
||||
</div>
|
||||
<div class="empty-state">
|
||||
<strong>没有真实草稿队列</strong>
|
||||
<p>当前只展示步骤结构:草稿、已校验、已备份、已写回。不会创建、放弃或写回任何文件。</p>
|
||||
<p>当前不做批量队列,也不自动保存草稿。请在智能体视图中选择单个文件,按“草稿、已校验、已备份、已写回”流程操作。</p>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user