feat: add safe agent writeback flow

This commit is contained in:
Yoilun
2026-05-25 21:06:32 +08:00
parent ba975112de
commit 4b5237fda2
19 changed files with 969 additions and 24 deletions

View File

@@ -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>

View File

@@ -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 }),
})
},
}

View 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' }))
})

View File

@@ -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以下为只读展示的已解析字段',

View File

@@ -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, ['草稿'])
})

View File

@@ -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 },
]

View File

@@ -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;

View File

@@ -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>

View File

@@ -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>