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

WIP: 补齐群聊 Agent 设置

[gstack-context]
Decisions: 管理面板覆盖 v3 全部可变 Agent 字段;保留外层语义化关闭按钮并移除内层重复按钮;只提交实际变更字段
Remaining: 完整单元与端到端测试、重启 8800、真实浏览器验收
Tried: 回归用例先命中双关闭按钮失败,修复后通过
Skill: /investigate
[/gstack-context]
samien 2 месяцев назад
Родитель
Сommit
9a97d34e6f

+ 10 - 1
src/client/api/groups.ts

@@ -29,7 +29,16 @@ export async function getGroupRoom(roomId: string): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms/${encodeURIComponent(roomId)}`)))
 }
 
-export interface AgentSeed { profile: string; displayName: string; description?: string; replyWithoutMention?: boolean }
+export interface AgentSeed {
+  profile: string
+  displayName: string
+  description?: string
+  replyWithoutMention?: boolean
+  model?: string | null
+  provider?: string | null
+  reasoningEffort?: string | null
+  fastMode?: boolean | null
+}
 
 export async function createGroupRoom(input: { name: string; cwd?: string; agents: AgentSeed[]; maxReplyRounds?: number }): Promise<GroupRoomDetail> {
   return normalizeGroupRoomDetail(unwrapData(await apiRequest<unknown>(`${BASE}/rooms`, {

+ 193 - 33
src/client/components/groups/GroupManager.vue

@@ -1,74 +1,234 @@
 <script setup lang="ts">
-import { reactive, watch } from 'vue'
+import { reactive, ref, watch } from 'vue'
+import type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
-import type { UiAgent, UiRoom } from './types'
+import type { UiRoom } from './types'
+
+type AgentSettingsPatch = Partial<Pick<GroupAgent,
+  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
+
+type FastModeDraft = '' | 'true' | 'false'
+
+interface AgentDraft {
+  displayName: string
+  description: string
+  enabled: boolean
+  replyWithoutMention: boolean
+  modelKey: string
+  reasoningEffort: string
+  fastMode: FastModeDraft
+}
+
+const REASONING_OPTIONS = [
+  { value: '', label: '跟随 Profile 默认' },
+  { value: 'none', label: '关闭' },
+  { value: 'minimal', label: '最小' },
+  { value: 'low', label: '低' },
+  { value: 'medium', label: '中' },
+  { value: 'high', label: '高' },
+  { value: 'xhigh', label: '极高' },
+  { value: 'max', label: '最大' },
+  { value: 'ultra', label: 'Ultra' },
+]
 
 const props = defineProps<{
   room: UiRoom
-  agents: UiAgent[]
+  agents: GroupAgent[]
   availableProfiles?: string[]
+  modelOptionsByProfile?: Record<string, ModelOption[]>
+  modelOptionsLoading?: Record<string, boolean>
+  modelOptionsError?: Record<string, string>
   busy?: boolean
 }>()
 
 const emit = defineEmits<{
   updateRoom: [patch: Partial<UiRoom>]
   addAgent: [profile: string]
-  updateAgent: [id: string, patch: Partial<UiAgent>]
+  loadModels: [profile: string]
+  updateAgent: [id: string, patch: AgentSettingsPatch]
   removeAgent: [id: string]
   interruptAgent: [id: string]
   archiveRoom: []
-  close: []
 }>()
 
-const form = reactive({ name: props.room.name, description: props.room.description || '', autoReply: props.room.autoReply ?? true, replyRounds: props.room.replyRounds ?? 3 })
+const form = reactive({ name: props.room.name, replyRounds: props.room.replyRounds ?? 3 })
+const expandedAgentId = ref('')
+const agentDrafts = reactive<Record<string, AgentDraft>>({})
+const dirtyAgents = reactive(new Set<string>())
+
+function modelKey(provider?: string | null, model?: string | null): string {
+  return provider && model ? JSON.stringify([provider, model]) : ''
+}
+
+function parseModelKey(value: string): { provider: string; model: string } | null {
+  if (!value) return null
+  try {
+    const parsed = JSON.parse(value) as unknown
+    if (Array.isArray(parsed) && parsed.length === 2 && parsed.every(item => typeof item === 'string' && item.trim())) {
+      return { provider: parsed[0]!, model: parsed[1]! }
+    }
+  } catch { /* malformed select values fall back to the Profile default */ }
+  return null
+}
+
+function draftFrom(agent: GroupAgent): AgentDraft {
+  return {
+    displayName: agent.displayName,
+    description: agent.description,
+    enabled: agent.enabled,
+    replyWithoutMention: agent.replyWithoutMention,
+    modelKey: modelKey(agent.provider, agent.model),
+    reasoningEffort: agent.reasoningEffort ?? '',
+    fastMode: agent.fastMode == null ? '' : agent.fastMode ? 'true' : 'false',
+  }
+}
 
 watch(() => props.room, room => {
   form.name = room.name
-  form.description = room.description || ''
-  form.autoReply = room.autoReply ?? true
   form.replyRounds = room.replyRounds ?? 3
 }, { deep: true })
 
-function save() {
+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)
+  }
+}, { deep: true, immediate: true })
+
+function saveRoom() {
   emit('updateRoom', {
     name: form.name.trim() || props.room.name,
-    description: form.description.trim(),
-    autoReply: form.autoReply,
     replyRounds: Math.min(12, Math.max(1, Number(form.replyRounds) || 1)),
   })
 }
+
+function toggleAgent(agent: GroupAgent) {
+  if (expandedAgentId.value === agent.id) {
+    expandedAgentId.value = ''
+    return
+  }
+  expandedAgentId.value = agent.id
+  if (!dirtyAgents.has(agent.id)) agentDrafts[agent.id] = draftFrom(agent)
+  emit('loadModels', agent.profile)
+}
+
+function markDirty(agentId: string) {
+  dirtyAgents.add(agentId)
+}
+
+function resetAgent(agent: GroupAgent) {
+  agentDrafts[agent.id] = draftFrom(agent)
+  dirtyAgents.delete(agent.id)
+}
+
+function modelOptionsFor(agent: GroupAgent): ModelOption[] {
+  const options = props.modelOptionsByProfile?.[agent.profile] ?? []
+  if (!agent.model || !agent.provider || options.some(option => option.id === agent.model && option.provider === agent.provider)) return options
+  return [{ id: agent.model, name: agent.model, provider: agent.provider }, ...options]
+}
+
+function fastModeValue(value: FastModeDraft): boolean | null {
+  return value === '' ? null : value === 'true'
+}
+
+function agentPatch(agent: GroupAgent): AgentSettingsPatch {
+  const draft = agentDrafts[agent.id]
+  if (!draft) return {}
+  const patch: AgentSettingsPatch = {}
+  const displayName = draft.displayName.trim()
+  const description = draft.description.trim()
+  if (displayName !== agent.displayName) patch.displayName = displayName
+  if (description !== agent.description) patch.description = description
+  if (draft.enabled !== agent.enabled) patch.enabled = draft.enabled
+  if (draft.replyWithoutMention !== agent.replyWithoutMention) patch.replyWithoutMention = draft.replyWithoutMention
+  if (draft.modelKey !== modelKey(agent.provider, agent.model)) {
+    const selected = parseModelKey(draft.modelKey)
+    patch.model = selected?.model ?? null
+    patch.provider = selected?.provider ?? null
+  }
+  if (draft.reasoningEffort !== (agent.reasoningEffort ?? '')) patch.reasoningEffort = draft.reasoningEffort || null
+  const nextFastMode = fastModeValue(draft.fastMode)
+  if (nextFastMode !== (agent.fastMode ?? null)) patch.fastMode = nextFastMode
+  return patch
+}
+
+function hasAgentChanges(agent: GroupAgent): boolean {
+  return Object.keys(agentPatch(agent)).length > 0
+}
+
+function saveAgent(agent: GroupAgent) {
+  const patch = agentPatch(agent)
+  if (!Object.keys(patch).length) return
+  dirtyAgents.delete(agent.id)
+  emit('updateAgent', agent.id, patch)
+}
+
+function statusLabel(status: GroupAgent['status']): string {
+  return { idle: '空闲', queued: '等待中', running: '正在回复', awaiting_input: '等待输入', unknown: '离线' }[status]
+}
 </script>
 
 <template>
   <div class="group-manager">
     <header>
       <span><small>群聊管理</small><strong>{{ room.name }}</strong></span>
-      <button class="icon-button" type="button" aria-label="关闭群聊管理" @click="emit('close')"><AppIcon name="close" /></button>
     </header>
 
     <section>
       <h3>房间</h3>
-      <label><span>名称</span><input v-model="form.name" maxlength="80" @change="save" /></label>
-      <label><span>说明</span><textarea v-model="form.description" rows="2" maxlength="240" @change="save" /></label>
-      <div class="setting-row">
-        <span><strong>自动回复</strong><small>Agent 根据新消息自动协作</small></span>
-        <button class="switch" :class="{ active: form.autoReply }" type="button" role="switch" :aria-checked="form.autoReply" @click="form.autoReply = !form.autoReply; save()"><i /></button>
-      </div>
-      <label class="rounds"><span>最多回复轮数</span><input v-model.number="form.replyRounds" type="number" min="1" max="12" @change="save" /></label>
+      <label><span>名称</span><input v-model="form.name" maxlength="80" @change="saveRoom" /></label>
+      <label class="rounds"><span>最多回复轮数</span><input v-model.number="form.replyRounds" type="number" min="1" max="12" @change="saveRoom" /></label>
     </section>
 
     <section>
       <div class="section-heading"><h3>成员 <em>{{ agents.length }}/8</em></h3></div>
       <div class="agent-list">
-        <article v-for="agent in agents" :key="agent.id">
-          <span class="agent-avatar">{{ agent.name.slice(0, 1).toUpperCase() }}<i :class="`status-${agent.status || 'idle'}`" /></span>
-          <span class="agent-copy"><strong>{{ agent.name }}</strong><small>{{ agent.profile || agent.id }} · {{ { idle: '空闲', working: '正在回复', offline: '离线', error: '异常' }[agent.status || 'idle'] }}</small></span>
-          <button v-if="agent.status === 'working'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
+        <article v-for="agent in agents" :key="agent.id" :class="{ expanded: expandedAgentId === agent.id }">
+          <span class="agent-avatar">{{ agent.displayName.slice(0, 1).toUpperCase() }}<i :class="`status-${agent.status}`" /></span>
+          <span class="agent-copy"><strong>{{ agent.displayName }}</strong><small>{{ agent.profile }} · {{ statusLabel(agent.status) }}</small></span>
+          <button class="agent-action" type="button" :title="`设置 ${agent.displayName}`" :aria-label="`设置${agent.displayName}`" @click="toggleAgent(agent)"><AppIcon name="settings" :size="14" /></button>
+          <button v-if="agent.status === 'running' || agent.status === 'queued'" class="agent-action" type="button" title="中断" @click="emit('interruptAgent', agent.id)"><AppIcon name="stop" :size="13" /></button>
           <button class="agent-action danger" type="button" title="移除" :disabled="agents.length <= 1" @click="emit('removeAgent', agent.id)"><AppIcon name="trash" :size="14" /></button>
-          <div class="agent-settings">
-            <label><input type="checkbox" :checked="agent.enabled !== false" @change="emit('updateAgent', agent.id, { enabled: ($event.target as HTMLInputElement).checked })" />启用</label>
-            <label><input type="checkbox" :checked="agent.autoReply !== false" @change="emit('updateAgent', agent.id, { autoReply: ($event.target as HTMLInputElement).checked })" />自动回复</label>
-          </div>
+
+          <fieldset v-if="expandedAgentId === agent.id && agentDrafts[agent.id]" class="agent-editor" :aria-label="`${agent.displayName} Agent 设置`">
+            <legend>Agent 设置</legend>
+            <div class="editor-grid">
+              <label><span>显示名称</span><input v-model="agentDrafts[agent.id].displayName" maxlength="100" aria-label="显示名称" @input="markDirty(agent.id)" /></label>
+              <label><span>职责说明</span><textarea v-model="agentDrafts[agent.id].description" rows="2" maxlength="500" aria-label="职责说明" @input="markDirty(agent.id)" /></label>
+              <label>
+                <span>模型</span>
+                <select v-model="agentDrafts[agent.id].modelKey" aria-label="模型" @focus="emit('loadModels', agent.profile)" @change="markDirty(agent.id)">
+                  <option value="">跟随 Profile 默认</option>
+                  <option v-for="option in modelOptionsFor(agent)" :key="modelKey(option.provider, option.id)" :value="modelKey(option.provider, option.id)">{{ option.name }} · {{ option.provider }}</option>
+                </select>
+                <small v-if="modelOptionsLoading?.[agent.profile]" class="editor-note">正在加载模型选项…</small>
+                <small v-else-if="modelOptionsError?.[agent.profile]" class="editor-note error">{{ modelOptionsError[agent.profile] }}</small>
+              </label>
+              <label>
+                <span>推理强度</span>
+                <select v-model="agentDrafts[agent.id].reasoningEffort" aria-label="推理强度" @change="markDirty(agent.id)">
+                  <option v-for="option in REASONING_OPTIONS" :key="option.value || 'default'" :value="option.value">{{ option.label }}</option>
+                </select>
+              </label>
+              <label>
+                <span>快速模式</span>
+                <select v-model="agentDrafts[agent.id].fastMode" aria-label="快速模式" @change="markDirty(agent.id)">
+                  <option value="">跟随 Profile 默认</option>
+                  <option value="true">开启</option>
+                  <option value="false">关闭</option>
+                </select>
+              </label>
+            </div>
+            <div class="editor-toggles">
+              <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>
+            <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>
+            </div>
+          </fieldset>
         </article>
       </div>
 
@@ -89,20 +249,20 @@ function save() {
 
 <style scoped>
 .group-manager { height: 100%; overflow-y: auto; padding: 0 17px 24px; }
-header { display: flex; position: sticky; z-index: 3; top: 0; min-height: 62px; align-items: center; justify-content: space-between; background: var(--surface); }
+header { display: flex; position: sticky; z-index: 3; top: 0; min-height: 62px; align-items: center; justify-content: space-between; padding-right: 40px; background: var(--surface); }
 header > span { display: flex; flex-direction: column; } header small { color: var(--text-muted); font-size: 9px; } header strong { margin-top: 2px; font-size: 13px; font-weight: 620; }
 section { padding: 17px 0; border-top: 1px solid var(--line); } section:first-of-type { border-top: 0; }
 h3 { margin: 0 0 13px; color: var(--text-secondary); font-size: 10px; font-weight: 650; letter-spacing: .06em; text-transform: uppercase; } h3 em { color: var(--text-muted); font-style: normal; font-weight: 500; }
 label { display: flex; flex-direction: column; gap: 5px; margin: 0 0 11px; color: var(--text-muted); font-size: 10px; }
 input, textarea, select { width: 100%; padding: 8px 9px; border: 1px solid var(--line); border-radius: 9px; outline: 0; resize: vertical; background: var(--surface-soft); color: var(--text-primary); font-size: 11px; } input:focus, textarea:focus, select:focus { border-color: var(--line-strong); box-shadow: 0 0 0 3px var(--focus-ring); }
-.setting-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 13px 0; }.setting-row > span { display: flex; flex-direction: column; }.setting-row strong { font-size: 11px; }.setting-row small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
-.switch { position: relative; width: 34px; height: 20px; padding: 0; border: 0; border-radius: 999px; background: var(--surface-hover); cursor: pointer; }.switch i { position: absolute; top: 3px; left: 3px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 140ms ease, background 140ms ease; }.switch.active { background: var(--accent); }.switch.active i { background: var(--text-on-solid); transform: translateX(14px); }
 .rounds { flex-direction: row; align-items: center; justify-content: space-between; }.rounds input { width: 62px; text-align: center; }
-.agent-list { display: flex; flex-direction: column; gap: 6px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) 28px 28px; align-items: center; gap: 7px; padding: 8px; border-radius: 10px; background: var(--surface-soft); }
-.agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-working { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-error { background: var(--danger); }
+.agent-list { display: flex; flex-direction: column; gap: 7px; }.agent-list article { display: grid; grid-template-columns: 32px minmax(0,1fr) repeat(3, 28px); align-items: center; gap: 7px; padding: 8px; border: 1px solid transparent; border-radius: 10px; background: var(--surface-soft); }.agent-list article.expanded { border-color: var(--line); background: var(--surface); }
+.agent-avatar { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }.agent-avatar i { position: absolute; right: -2px; bottom: -2px; width: 8px; height: 8px; border: 2px solid var(--surface-soft); border-radius: 50%; background: var(--text-muted); }.agent-avatar .status-running, .agent-avatar .status-queued { background: var(--warning); }.agent-avatar .status-idle { background: var(--success); }.agent-avatar .status-awaiting_input { background: var(--warning); }
 .agent-copy { display: flex; min-width: 0; flex-direction: column; }.agent-copy strong, .agent-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.agent-copy strong { font-size: 11px; }.agent-copy small { margin-top: 2px; color: var(--text-muted); font-size: 9px; }
 .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-settings { display: flex; grid-column: 2 / -1; align-items: center; gap: 15px; padding-top: 2px; }.agent-settings label { display: flex; flex-direction: row; align-items: center; gap: 5px; margin: 0; }.agent-settings input { width: auto; margin: 0; accent-color: var(--accent); }
+.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); }
+.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; }
 </style>

+ 4 - 0
src/client/utils/normalize.ts

@@ -343,12 +343,16 @@ export function normalizeGroupCapabilities(value: unknown): GroupCapabilities {
 export function normalizeGroupAgent(value: unknown): GroupAgent {
   const source = record(value)
   const status = string(source.status, 'unknown') as GroupAgent['status']
+  const rawFastMode = pick(source, 'fastMode', 'fast_mode')
   return {
     id: string(source.id), roomId: string(pick(source, 'roomId', 'room_id')),
     profile: string(source.profile), displayName: string(pick(source, 'displayName', 'display_name'), string(source.profile)),
     description: string(source.description), storedSessionId: string(pick(source, 'storedSessionId', 'stored_session_id')) || null,
     lastContextMessageSeq: number(pick(source, 'lastContextMessageSeq', 'last_context_message_seq')),
     enabled: bool(source.enabled, true), replyWithoutMention: bool(pick(source, 'replyWithoutMention', 'reply_without_mention')),
+    model: string(source.model) || null, provider: string(source.provider) || null,
+    reasoningEffort: string(pick(source, 'reasoningEffort', 'reasoning_effort')) || null,
+    fastMode: rawFastMode == null ? null : bool(rawFastMode),
     createdAt: number(pick(source, 'createdAt', 'created_at')), updatedAt: number(pick(source, 'updatedAt', 'updated_at')),
     status: ['idle', 'queued', 'running', 'awaiting_input'].includes(status) ? status : 'unknown',
   }

+ 34 - 6
src/client/views/GroupsView.vue

@@ -2,6 +2,7 @@
 import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL } from '@shared/types'
+import type { GroupAgent, ModelOption } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
 import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import EmptyState from '@/components/common/EmptyState.vue'
@@ -21,6 +22,7 @@ import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
 import { loadComposerFile } from '@/components/workspace/pendingComposer'
 import { readAgentShowThinking, writeAgentShowThinking } from '@/utils/sessionPreferences'
 import { agentToUi, groupInteraction, groupMessageToUi, roomSidebarItem, roomToUi } from '@/components/workspace/viewModels'
+import { getModels } from '@/api/profiles'
 import { useAuthStore } from '@/stores/auth'
 import { useGroupsStore } from '@/stores/groups'
 
@@ -36,6 +38,10 @@ const quoted = ref<UiMessage | null>(null)
 const preview = ref<UiLibraryItem | null>(null)
 const mediaPreviewIndex = ref<number | null>(null)
 const composer = ref<InstanceType<typeof ComposerShell> | null>(null)
+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 filteredRooms = computed(() => groups.rooms
   .filter(room => !room.archived)
@@ -51,6 +57,7 @@ const room = computed(() => groups.selectedRoom ? roomToUi(groups.selectedRoom)
 const profiles = computed(() => auth.profiles.map(profile => profile.name))
 const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent => agent.profile === profile)))
 const uploadsEnabled = computed(() => auth.groupUploadsEnabled)
+const managerBusy = computed(() => groups.isLoading || Object.values(agentUpdateBusy.value).some(Boolean))
 const reference = computed<ComposerReference | null>(() => quoted.value ? { id: quoted.value.id, author: quoted.value.author, content: quoted.value.content } : null)
 const mentionNames = computed(() => ['所有人', ...groups.agents.map(agent => agent.displayName || agent.profile)])
 const mentionOptions = computed<ComposerOption[]>(() => [
@@ -102,9 +109,27 @@ async function addAgent(profile: string) {
   await groups.addAgent(groups.selectedRoom.id, { profile, displayName: auth.profiles.find(item => item.name === profile)?.agentName || auth.profiles.find(item => item.name === profile)?.displayName || profile, replyWithoutMention: true })
 }
 
-async function updateAgent(id: string, patch: { enabled?: boolean; autoReply?: boolean }) {
+type AgentSettingsPatch = Partial<Pick<GroupAgent,
+  'displayName' | 'description' | 'enabled' | 'replyWithoutMention' | 'model' | 'provider' | 'reasoningEffort' | 'fastMode'>>
+
+async function loadAgentModels(profile: string) {
+  if (modelOptionsLoading.value[profile] || Object.prototype.hasOwnProperty.call(modelOptionsByProfile.value, profile)) return
+  modelOptionsLoading.value = { ...modelOptionsLoading.value, [profile]: true }
+  modelOptionsError.value = { ...modelOptionsError.value, [profile]: '' }
+  try {
+    modelOptionsByProfile.value = { ...modelOptionsByProfile.value, [profile]: await getModels(profile) }
+  } catch (cause) {
+    modelOptionsError.value = { ...modelOptionsError.value, [profile]: cause instanceof Error ? cause.message : '模型选项加载失败' }
+  } finally {
+    modelOptionsLoading.value = { ...modelOptionsLoading.value, [profile]: false }
+  }
+}
+
+async function updateAgent(id: string, patch: AgentSettingsPatch) {
   if (!groups.selectedRoom) return
-  await groups.updateAgent(groups.selectedRoom.id, id, { enabled: patch.enabled, replyWithoutMention: patch.autoReply })
+  agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: true }
+  try { await groups.updateAgent(groups.selectedRoom.id, id, patch) }
+  finally { agentUpdateBusy.value = { ...agentUpdateBusy.value, [id]: false } }
 }
 
 async function archiveRoom() {
@@ -167,7 +192,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`" :inspector-open="managerOpen && !!room" @close-inspector="managerOpen = false">
+  <WorkspaceView sidebar-title="群聊" :sidebar-subtitle="groups.availability === 'available' ? `${filteredRooms.length} 个活跃房间` : `9119 群聊 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL}`" :inspector-open="managerOpen && !!room" inspector-close-label="关闭群聊管理" @close-inspector="managerOpen = false">
     <template #sidebar-action>
       <button class="sidebar-primary-action" type="button" :disabled="groups.availability !== 'available'" title="新建群聊" aria-label="新建群聊" @click="createOpen = true">
         <YaoYaoSidebarIcon name="add" />
@@ -245,12 +270,15 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
       <GroupManager
         v-if="room && groups.selectedRoom"
         :room="room"
-        :agents="agents"
+        :agents="groups.agents"
         :available-profiles="availableProfiles"
-        :busy="groups.isLoading"
-        @close="managerOpen = false"
+        :busy="managerBusy"
+        :model-options-by-profile="modelOptionsByProfile"
+        :model-options-loading="modelOptionsLoading"
+        :model-options-error="modelOptionsError"
         @update-room="updateRoom"
         @add-agent="addAgent"
+        @load-models="loadAgentModels"
         @update-agent="updateAgent"
         @remove-agent="groups.removeAgent(groups.selectedRoom.id, $event)"
         @interrupt-agent="groups.interruptAgent($event, groups.selectedRoom.id)"

+ 4 - 0
src/shared/types.ts

@@ -271,6 +271,10 @@ export interface GroupAgent {
   lastContextMessageSeq: number
   enabled: boolean
   replyWithoutMention: boolean
+  model?: string | null
+  provider?: string | null
+  reasoningEffort?: string | null
+  fastMode?: boolean | null
   createdAt: number
   updatedAt: number
   status: GroupAgentStatus

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

@@ -95,6 +95,42 @@ test('navigates every 9119 workspace without blank transitions', async ({ page }
   expect(consoleErrors).toEqual([])
 })
 
+test('edits every protocol v3 Agent setting with one inspector close control', async ({ page }) => {
+  await page.getByRole('button', { name: '群聊' }).click()
+  await expect(page.getByRole('heading', { name: '设计与工程协作' })).toBeVisible()
+  await page.getByRole('button', { name: '管理群聊' }).click()
+
+  await expect(page.getByRole('button', { name: '关闭群聊管理' })).toHaveCount(1)
+  await expect(page.getByRole('button', { name: '关闭预览' })).toHaveCount(0)
+  await page.getByRole('button', { name: '设置夭夭' }).click()
+
+  const editor = page.getByRole('group', { name: '夭夭 Agent 设置' })
+  await expect(editor.getByLabel('显示名称')).toHaveValue('夭夭')
+  await expect(editor.getByLabel('职责说明')).toHaveValue('主 Agent')
+  await expect(editor.getByLabel('模型')).toHaveValue(JSON.stringify(['openai', 'gpt-5.6']))
+  await expect(editor.getByLabel('推理强度')).toHaveValue('high')
+  await expect(editor.getByLabel('快速模式')).toHaveValue('true')
+
+  await editor.getByLabel('模型').selectOption(JSON.stringify(['openai', 'gpt-5.5']))
+  await editor.getByLabel('推理强度').selectOption('xhigh')
+  await editor.getByLabel('快速模式').selectOption('false')
+  await editor.getByLabel('自动回复').uncheck()
+  const updateRequest = page.waitForRequest(request => request.method() === 'PATCH' && /\/api\/app\/groups\/rooms\/[^/]+\/agents\/[^/]+$/.test(new URL(request.url()).pathname))
+  await editor.getByRole('button', { name: '保存 Agent 设置' }).click()
+  expect((await updateRequest).postDataJSON()).toMatchObject({
+    model: 'gpt-5.5', provider: 'openai', reasoningEffort: 'xhigh', fastMode: false, replyWithoutMention: false,
+  })
+  await expect(editor.getByLabel('模型')).toHaveValue(JSON.stringify(['openai', 'gpt-5.5']))
+
+  await editor.getByLabel('模型').selectOption(JSON.stringify(['openai', 'gpt-5.6']))
+  await editor.getByLabel('推理强度').selectOption('high')
+  await editor.getByLabel('快速模式').selectOption('true')
+  await editor.getByLabel('自动回复').check()
+  const restoreResponse = 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 restoreResponse).status()).toBe(200)
+})
+
 test('previews an octet-stream Markdown file from the file library', async ({ page }) => {
   await page.route('**/api/app/files**', async route => {
     const url = new URL(route.request().url())

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

@@ -56,7 +56,7 @@ const messages = [
   { id: 'message-delegation', role: 'user', content: '[ASYNC DELEGATION BATCH COMPLETE]\n后台子任务已经完成。', display_kind: 'async_delegation_complete', display_metadata: { task_count: 2, completed_count: 2, failed_count: 0, duration_seconds: 71 }, timestamp: now() - 140 },
   { id: 'message-system', role: 'user', content: '[System: The active model for this chat has changed to gpt-5.6-terra via provider openai.]', timestamp: now() - 130 },
 ]
-const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
+const groupAgent = { id: agentId, roomId, profile: 'yaoyao', displayName: '夭夭', description: '主 Agent', enabled: true, replyWithoutMention: true, model: 'gpt-5.6', provider: 'openai', reasoningEffort: 'high', fastMode: true, status: 'idle', createdAt: now() - 2000, updatedAt: now() }
 const groupMessage = { seq: 1, id: '44444444-4444-4444-8444-444444444444', roomId, senderKind: 'human', senderId: 'demo-user', senderName: '验收用户', content: '大家好,检查一下群聊输入框。', status: 'completed', createdAt: now() - 120, updatedAt: now() - 120 }
 const groupAssistantMessage = { seq: 2, id: '55555555-5555-4555-8555-555555555555', roomId, senderKind: 'agent', senderId: agentId, senderName: '夭夭', content: '历史兼容:MEDIA:/brand/AppIcon-1024.png', status: 'completed', createdAt: now() - 100, updatedAt: now() - 100 }
 const groupMessages = [groupMessage, groupAssistantMessage]
@@ -114,6 +114,14 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/plugins/yaoyao/v1/capabilities') return json(response, 200, { protocolVersion: 3, journalEpoch: epoch, latestCursor: 0, limits: { maxAgentsPerRoom: 8, maxMessageBytes: 65536, maxMessagePageSize: 100, defaultMaxReplyRounds: 3, unlimitedReplyRoundsValue: -1, maxAgentDisplayNameLength: 100 }, eventTypes: ['message.upsert', 'agent.status'] })
   if (url.pathname === '/api/plugins/yaoyao/v1/rooms') return json(response, 200, { items: [room], nextCursor: null })
   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)
+    for (const key of ['displayName', 'description', 'enabled', 'replyWithoutMention', 'model', 'provider', 'reasoningEffort', 'fastMode']) {
+      if (Object.prototype.hasOwnProperty.call(input, key)) groupAgent[key] = input[key]
+    }
+    groupAgent.updatedAt = now()
+    return json(response, 200, groupAgent)
+  }
   if (url.pathname === `/api/plugins/yaoyao/v1/rooms/${roomId}/messages`) return json(response, 200, { items: groupMessages })
   if (url.pathname === '/api/plugins/yaoyao/files') return json(response, 200, { items: [{ id: 1, path: '/tmp/demo-report.pdf', name: 'demo-report.pdf', extension: 'pdf', mimeType: 'application/pdf', size: 204800, modifiedAt: now(), exists: true, origins: [{ profile: 'yaoyao', sessionId: 'session-demo', sessionTitle: '夭夭 Web 验收会话', messageId: 'message-assistant', authorKind: 'assistant', authorName: '夭夭', observedAt: now() }] }], nextCursor: null, total: 1 })
   if (url.pathname === '/api/plugins/yaoyao/1/download') {