Просмотр исходного кода

WIP: 保留失败的 Agent 设置草稿

[gstack-context]
Decisions: PATCH 失败时保留草稿并显示错误;仅在服务端状态匹配草稿后清除脏状态
Remaining: 最终完整测试与真实 8800 验收
Tried: 独立审查发现失败路径,新增 409 回归并通过
Skill: /investigate
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
21d278bcf9

+ 12 - 2
src/client/components/groups/GroupManager.vue

@@ -38,6 +38,7 @@ const props = defineProps<{
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsError?: Record<string, string>
+  agentUpdateError?: Record<string, string>
   busy?: boolean
 }>()
 
@@ -45,6 +46,7 @@ const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
   addAgent: [profile: string]
   loadModels: [profile: string]
+  clearAgentError: [id: string]
   updateAgent: [id: string, patch: AgentSettingsPatch]
   removeAgent: [id: string]
   interruptAgent: [id: string]
@@ -92,7 +94,12 @@ watch(() => props.agents, agents => {
   const ids = new Set(agents.map(agent => agent.id))
   for (const id of Object.keys(agentDrafts)) if (!ids.has(id)) delete agentDrafts[id]
   for (const agent of agents) {
-    if (!agentDrafts[agent.id] || !dirtyAgents.has(agent.id)) agentDrafts[agent.id] = draftFrom(agent)
+    if (!agentDrafts[agent.id] || !dirtyAgents.has(agent.id)) {
+      agentDrafts[agent.id] = draftFrom(agent)
+    } else if (!Object.keys(agentPatch(agent)).length) {
+      dirtyAgents.delete(agent.id)
+      agentDrafts[agent.id] = draftFrom(agent)
+    }
   }
 }, { deep: true, immediate: true })
 
@@ -115,11 +122,13 @@ function toggleAgent(agent: GroupAgent) {
 
 function markDirty(agentId: string) {
   dirtyAgents.add(agentId)
+  emit('clearAgentError', agentId)
 }
 
 function resetAgent(agent: GroupAgent) {
   agentDrafts[agent.id] = draftFrom(agent)
   dirtyAgents.delete(agent.id)
+  emit('clearAgentError', agent.id)
 }
 
 function modelOptionsFor(agent: GroupAgent): ModelOption[] {
@@ -160,7 +169,6 @@ function hasAgentChanges(agent: GroupAgent): boolean {
 function saveAgent(agent: GroupAgent) {
   const patch = agentPatch(agent)
   if (!Object.keys(patch).length) return
-  dirtyAgents.delete(agent.id)
   emit('updateAgent', agent.id, patch)
 }
 
@@ -224,6 +232,7 @@ function statusLabel(status: GroupAgent['status']): string {
               <label><input v-model="agentDrafts[agent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(agent.id)" />启用</label>
               <label><input v-model="agentDrafts[agent.id].replyWithoutMention" type="checkbox" aria-label="自动回复" @change="markDirty(agent.id)" />自动回复</label>
             </div>
+            <p v-if="agentUpdateError?.[agent.id]" class="agent-save-error" role="alert">{{ agentUpdateError[agent.id] }}</p>
             <div class="editor-actions">
               <button class="quiet-button" type="button" :disabled="busy || !hasAgentChanges(agent)" @click="resetAgent(agent)">取消更改</button>
               <button class="save-agent" type="button" :disabled="busy || !hasAgentChanges(agent)" @click="saveAgent(agent)">保存 Agent 设置</button>
@@ -262,6 +271,7 @@ input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(-
 .agent-action { display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 7px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-action:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-action.danger:hover { color: var(--danger); }.agent-action:disabled { cursor: not-allowed; opacity: .25; }
 .agent-editor { display: block; grid-column: 1 / -1; min-width: 0; margin: 4px 0 0; padding: 12px 0 2px; border: 0; border-top: 1px solid var(--line); }.agent-editor legend { padding: 0 0 10px; color: var(--text-secondary); font-size: 10px; font-weight: 650; }.editor-grid { display: grid; gap: 0; }.editor-grid label:last-child { margin-bottom: 7px; }.editor-note { margin: -1px 2px 0; color: var(--text-muted); font-size: 9px; }.editor-note.error { color: var(--danger); }
 .editor-toggles { display: flex; align-items: center; gap: 16px; padding: 3px 0 12px; }.editor-toggles label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; color: var(--text-secondary); }.editor-toggles input { width: auto; margin: 0; accent-color: var(--accent); }
+.agent-save-error { margin: 0 0 10px; color: var(--danger); font-size: 9px; line-height: 1.45; }
 .editor-actions { display: flex; justify-content: flex-end; gap: 7px; }.editor-actions button { min-height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer; font-size: 10px; }.editor-actions button:disabled { cursor: not-allowed; opacity: .35; }.save-agent { border: 1px solid var(--accent); background: var(--accent); color: var(--text-on-solid); }
 .add-agent { margin-top: 8px; }.add-agent select { cursor: pointer; }
 .danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 12px; }.danger-zone strong { font-size: 11px; }.danger-zone p { margin: 3px 0 0; color: var(--text-muted); font-size: 9px; line-height: 1.5; }.danger-zone .quiet-button { display: flex; flex: 0 0 auto; gap: 6px; color: var(--danger); font-size: 10px; }

+ 10 - 0
src/client/views/GroupsView.vue

@@ -42,6 +42,7 @@ const modelOptionsByProfile = ref<Record<string, ModelOption[]>>({})
 const modelOptionsLoading = ref<Record<string, boolean>>({})
 const modelOptionsError = ref<Record<string, string>>({})
 const agentUpdateBusy = ref<Record<string, boolean>>({})
+const agentUpdateError = ref<Record<string, string>>({})
 
 const filteredRooms = computed(() => groups.rooms
   .filter(room => !room.archived)
@@ -128,10 +129,17 @@ async function loadAgentModels(profile: string) {
 async function updateAgent(id: string, patch: AgentSettingsPatch) {
   if (!groups.selectedRoom) return
   agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
+  agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
   try { await groups.updateAgent(groups.selectedRoom.id, id, patch) }
+  catch (cause) { agentUpdateError.value = { ...agentUpdateError.value, [id]: cause instanceof Error ? cause.message : 'Agent 设置保存失败' } }
   finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
 }
 
+function clearAgentError(id: string) {
+  if (!agentUpdateError.value[id]) return
+  agentUpdateError.value = { ...agentUpdateError.value, [id]: '' }
+}
+
 async function archiveRoom() {
   if (!groups.selectedRoom) return
   await groups.archiveRoom(groups.selectedRoom.id)
@@ -276,9 +284,11 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :model-options-by-profile="modelOptionsByProfile"
         :model-options-loading="modelOptionsLoading"
         :model-options-error="modelOptionsError"
+        :agent-update-error="agentUpdateError"
         @update-room="updateRoom"
         @add-agent="addAgent"
         @load-models="loadAgentModels"
+        @clear-agent-error="clearAgentError"
         @update-agent="updateAgent"
         @remove-agent="groups.removeAgent(groups.selectedRoom.id, $event)"
         @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"

+ 10 - 0
tests/e2e/workspace.spec.ts

@@ -111,6 +111,16 @@ test('edits every protocol v3 Agent setting with one inspector close control', a
   await expect(editor.getByLabel('推理强度')).toHaveValue('high')
   await expect(editor.getByLabel('快速模式')).toHaveValue('true')
 
+  await editor.getByLabel('显示名称').fill('所有人')
+  const rejectedResponse = page.waitForResponse(response => response.request().method() === 'PATCH' && /\/api\/app\/groups\/rooms\/[^/]+\/agents\/[^/]+$/.test(new URL(response.url()).pathname))
+  await editor.getByRole('button', { name: '保存 Agent 设置' }).click()
+  expect((await rejectedResponse).status()).toBe(409)
+  await expect(editor.getByRole('alert')).toContainText('成员名称不能使用“所有人”')
+  await page.getByRole('button', { name: '设置夭夭' }).click()
+  await page.getByRole('button', { name: '设置夭夭' }).click()
+  await expect(editor.getByLabel('显示名称')).toHaveValue('所有人')
+  await editor.getByLabel('显示名称').fill('夭夭')
+
   await editor.getByLabel('模型').selectOption(JSON.stringify(['openai', 'gpt-5.5']))
   await editor.getByLabel('推理强度').selectOption('xhigh')
   await editor.getByLabel('快速模式').selectOption('false')

+ 1 - 0
tests/fixtures/fake-hermes.mjs

@@ -116,6 +116,7 @@ const server = createServer(async (request, response) => {
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}`) return json(response, 200, { ...room, agents: [groupAgent], runs: [], pendingInteractions: [], latestCursor: 0 })
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/agents/${agentId}` && request.method === 'PATCH') {
     const input = await body(request)
+    if (input.displayName === '所有人') return json(response, 409, { detail: '成员名称不能使用“所有人”' })
     for (const key of ['displayName', 'description', 'enabled', 'replyWithoutMention', 'model', 'provider', 'reasoningEffort', 'fastMode']) {
       if (Object.prototype.hasOwnProperty.call(input, key)) groupAgent[key] = input[key]
     }