Преглед изворни кода

完善 Web 远程 Agent 群聊标识与重命名

samien пре 2 месеци
родитељ
комит
15e99cc1ee

+ 2 - 0
src/client/components/common/AppIcon.vue

@@ -44,6 +44,7 @@ type IconName =
   | 'pin'
   | 'pin-filled'
   | 'pin-off'
+  | 'globe'
 
 withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
 </script>
@@ -116,6 +117,7 @@ withDefaults(defineProps<{ name: IconName; size?: number }>(), { size: 18 })
     <template v-else-if="name === 'pin'"><path d="M9 4v6l-2 4v2h10v-2l-2-4V4M12 16v5M8 4h8" /></template>
     <template v-else-if="name === 'pin-filled'"><path d="M8 3a1 1 0 0 0 0 2h1v4.72l-2.8 4.7A1 1 0 0 0 7.06 16H11v5a1 1 0 1 0 2 0v-5h3.94a1 1 0 0 0 .86-1.58L15 9.72V5h1a1 1 0 1 0 0-2z" fill="currentColor" stroke="none" /></template>
     <template v-else-if="name === 'pin-off'"><path d="M9 4v6l-2 4v2h10v-2l-2-4V4M12 16v5M8 4h8" /><path d="M4 4l16 16" /></template>
+    <template v-else-if="name === 'globe'"><circle cx="12" cy="12" r="9" /><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" /></template>
   </svg>
 </template>
 

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

@@ -42,6 +42,7 @@ const props = withDefaults(defineProps<{
   modelOptionsByProfile?: Record<string, ModelOption[]>
   modelOptionsLoading?: Record<string, boolean>
   modelOptionsError?: Record<string, string>
+  remoteServerAddresses?: Record<string, string>
   agentUpdateError?: Record<string, string>
   managerError?: string
   busy?: boolean
@@ -141,6 +142,8 @@ function canRemoveAgent(agent: GroupAgent): boolean {
   return props.agents.some(candidate => candidate.id !== agent.id && candidate.enabled)
 }
 
+function isRemoteAgent(agent: GroupAgent): boolean { return agent.nodeId !== 'local' }
+
 function removeAgentTitle(agent: GroupAgent): string {
   return canRemoveAgent(agent) ? '移除' : agent.isHost ? '需要另一位已启用成员才能移除主持人' : '房间必须保留至少一位成员'
 }
@@ -180,7 +183,7 @@ function agentPatch(agent: GroupAgent): AgentSettingsPatch {
   const patch: AgentSettingsPatch = {}
   const displayName = draft.displayName.trim()
   const description = draft.description.trim()
-  if (displayName !== agent.displayName) patch.displayName = displayName
+  if (isRemoteAgent(agent) && 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
@@ -276,7 +279,7 @@ function statusLabel(status: GroupAgent['status']): string {
         <fieldset class="agent-editor">
           <legend class="sr-only">{{ selectedAgent.displayName }} Agent 设置</legend>
           <div class="editor-grid">
-            <label><span>显示名称</span><input v-model="agentDrafts[selectedAgent.id].displayName" maxlength="100" aria-label="显示名称" @input="markDirty(selectedAgent.id)" /></label>
+            <label v-if="isRemoteAgent(selectedAgent)"><span>群内名称<small>仅影响此群,会同步到其他设备;不修改远端 Agent 名称。</small></span><input v-model="agentDrafts[selectedAgent.id].displayName" maxlength="100" aria-label="群内名称" @input="markDirty(selectedAgent.id)" /></label>
             <label><span>职责说明</span><textarea v-model="agentDrafts[selectedAgent.id].description" rows="3" maxlength="500" aria-label="职责说明" @input="markDirty(selectedAgent.id)" /></label>
             <label>
               <span>模型</span>
@@ -302,6 +305,7 @@ function statusLabel(status: GroupAgent['status']): string {
               </select>
             </label>
           </div>
+          <p v-if="isRemoteAgent(selectedAgent) && remoteServerAddresses?.[selectedAgent.nodeId]" class="remote-agent-address">远程地址 · {{ remoteServerAddresses[selectedAgent.nodeId] }}</p>
           <div class="editor-toggles">
             <label><input v-model="agentDrafts[selectedAgent.id].enabled" type="checkbox" aria-label="启用" @change="markDirty(selectedAgent.id)" />启用</label>
             <label><input v-model="agentDrafts[selectedAgent.id].replyWithoutMention" type="checkbox" :aria-label="hostEnabled ? '无需 @ 也回复' : '自动回复'" @change="markDirty(selectedAgent.id)" />{{ hostEnabled ? '无需 @ 也回复' : '自动回复' }}</label>
@@ -335,6 +339,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-settings-backdrop { position: fixed; z-index: 50; inset: 0; display: grid; place-items: center; padding: 24px; background: color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog { width: min(100%, 520px); max-height: min(720px, calc(100vh - 48px)); overflow: auto; border: 1px solid var(--line); border-radius: 16px; background: var(--surface); box-shadow: 0 24px 72px color-mix(in srgb, var(--text-primary) 24%, transparent); }.agent-settings-dialog header { min-height: 68px; padding: 0 20px; border-bottom: 1px solid var(--line); border-radius: 16px 16px 0 0; }.agent-settings-close { display: grid; place-items: center; width: 32px; height: 32px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--text-muted); cursor: pointer; }.agent-settings-close:hover { background: var(--surface-hover); color: var(--text-primary); }.agent-editor { display: block; min-width: 0; margin: 0; padding: 20px; border: 0; }.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }.editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 12px; }.editor-grid > label:nth-child(1), .editor-grid > label:nth-child(2) { grid-column: 1 / -1; }.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); }
+.remote-agent-address { margin: 0 0 10px; overflow-wrap: anywhere; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .host-explanation { margin: -4px 0 12px; color: var(--text-muted); font-size: 9px; line-height: 1.5; }
 .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); }

+ 2 - 2
src/client/components/messages/MessageTimeline.vue

@@ -278,7 +278,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
             </template>
             <template v-else>
             <div v-if="message.role !== 'assistant' || showAssistantIdentity" class="message__meta">
-              <strong>{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
+              <strong><AppIcon v-if="message.isRemoteAgent" class="message__remote-agent" name="globe" :size="12" />{{ message.role === 'user' ? '你' : message.author || message.profile || (message.role === 'assistant' ? 'Agent' : '系统') }}</strong>
               <span v-if="message.metadata" class="message__execution">{{ message.metadata }}</span>
               <time>{{ formatTime(message.createdAt) }}</time>
               <span v-if="message.status && !['settled', 'streaming'].includes(message.status)">{{ { preparing: '准备中', attached: '附件已就绪', pending: '等待回执', accepted: '已接收', streaming: '生成中', settled: '已完成', failed: '发送失败', 'unknown-receipt': '回执未知' }[message.status] }}</span>
@@ -388,7 +388,7 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom })
 .message { position: relative; display: flex; max-width: 100%; gap: 10px; margin: 0 0 21px; }
 .message__avatar { display: grid; place-items: center; width: 27px; height: 27px; flex: 0 0 27px; border-radius: 9px; background: var(--accent); color: var(--text-on-solid); font-size: 10px; font-weight: 700; }
 .message__body { position: relative; min-width: 0; max-width: min(680px, calc(100% - 37px)); }
-.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__meta span { color: var(--warning); }.message__meta .message__execution { overflow: hidden; max-width: 230px; color: var(--text-secondary); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
+.message__meta { display: flex; min-height: 20px; align-items: center; gap: 7px; color: var(--text-muted); font-size: 9px; }.message__meta strong { display: inline-flex; align-items: center; color: var(--text-primary); font-size: 11px; font-weight: 600; }.message__remote-agent { margin-right: 4px; color: var(--accent); }.message__meta span { color: var(--warning); }.message__meta .message__execution { overflow: hidden; max-width: 230px; color: var(--text-secondary); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
 .message__content { color: var(--text-primary); }
 .message--user { justify-content: flex-end; margin-top: 27px; }
 .message--user .message__body { max-width: min(610px, 76%); padding: 11px 15px; border-radius: 20px 20px 6px 20px; background: #eceff3; }

+ 1 - 0
src/client/components/messages/types.ts

@@ -24,6 +24,7 @@ export type UiMessage = {
   id: string
   role: 'user' | 'assistant' | 'system' | 'tool'
   author?: string
+  isRemoteAgent?: boolean
   content: string
   reasoning?: string
   createdAt?: string | number | Date

+ 6 - 2
src/client/components/workspace/viewModels.ts

@@ -195,7 +195,7 @@ export function roomSidebarItem(room: GroupRoomSummary): SidebarItem {
   }
 }
 
-export function groupMessageToUi(message: GroupMessage): UiMessage {
+export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = []): UiMessage {
   const tools = (message.toolState || []).map((entry, index) => {
     const value = record(entry)
     const rawStatus = string(value.status, 'success')
@@ -207,10 +207,14 @@ export function groupMessageToUi(message: GroupMessage): UiMessage {
   const reasoning = execution?.actualReasoningEffort || execution?.requestedReasoningEffort
   const fast = execution?.actualFastMode ?? execution?.requestedFastMode
   const metadata = model ? `${fast ? '⚡ ' : ''}${model}${reasoning ? ` · ${reasoning}` : ''}` : undefined
+  const sender = message.senderKind === 'agent'
+    ? agents.find(agent => agent.id === message.senderId)
+    : undefined
   return {
     id: message.id,
     role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
-    author: message.senderName,
+    author: sender?.displayName || message.senderName,
+    isRemoteAgent: message.senderKind === 'agent' && sender != null && sender.nodeId !== 'local',
     content: message.content,
     reasoning: message.reasoning,
     createdAt: message.createdAt < 10_000_000_000 ? message.createdAt * 1000 : message.createdAt,

+ 9 - 1
src/client/views/GroupsView.vue

@@ -133,10 +133,17 @@ const sidebarItems = computed<SidebarItem[]>(() => {
   }
   return items
 })
-const messages = computed(() => groups.messages.map(groupMessageToUi))
+const messages = computed(() => groups.messages.map(message => groupMessageToUi(message, groups.agents)))
 const conversationMediaItems = computed(() => mediaItemsFromMessages(messages.value))
 const lightboxMedia = computed(() => conversationMediaItems.value.map(item => ({ url: item.previewUrl || item.downloadUrl || '', name: item.name, type: item.kind as 'image' | 'video' })).filter(item => item.url))
 const agents = computed(() => groups.agents.map(agentToUi))
+function serverAddress(value: string): string {
+  try { return new URL(value).host }
+  catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
+}
+const remoteServerAddresses = computed(() => Object.fromEntries(groups.nodes
+  .filter(node => node.nodeId && node.serverUrl)
+  .map(node => [node.nodeId, serverAddress(node.serverUrl)])))
 const hostAgent = computed(() => groups.hostProtocol ? groups.agents.find(agent => agent.isHost) : undefined)
 const connected = computed(() => ['connected', 'ready'].includes(groups.connectionState))
 const activeInteraction = computed(() => groupInteraction(groups.pendingInteractions[0]))
@@ -680,6 +687,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         :model-options-by-profile="modelOptionsByProfile"
         :model-options-loading="modelOptionsLoading"
         :model-options-error="modelOptionsError"
+        :remote-server-addresses="remoteServerAddresses"
         :agent-update-error="agentUpdateError"
         :manager-error="managerError"
         @update-room="updateRoom"

+ 61 - 1
tests/client/groupHost.test.ts

@@ -1,9 +1,10 @@
 import { mount } from '@vue/test-utils'
 import { nextTick } from 'vue'
 import { describe, expect, it } from 'vitest'
-import type { GroupAgent } from '@shared/types'
+import type { GroupAgent, GroupMessage } from '@shared/types'
 import CreateGroupDialog from '@/components/groups/CreateGroupDialog.vue'
 import GroupManager from '@/components/groups/GroupManager.vue'
+import { groupMessageToUi } from '@/components/workspace/viewModels'
 
 const profiles = (...names: string[]) => names.map(name => ({
   id: `local|${name}`, profile: name, displayName: name, nodeId: 'local', nodeLabel: '当前 Hermes',
@@ -192,4 +193,63 @@ describe('group host controls', () => {
       instructions: '新规则\n输出中文',
     })
   })
+
+  it('only allows remote Agents to rename themselves within the room', async () => {
+    const local = agent('agent-1', 'yaoyao', '夭夭', false, false)
+    const remote = {
+      ...agent('agent-2', 'yaoer', '远端夭夭', false, false),
+      nodeId: '11111111-1111-4111-8111-111111111111',
+      nodeLabel: '工作室 Mac',
+    }
+    const wrapper = mount(GroupManager, {
+      props: {
+        room: { id: 'room-1', name: '群聊', memberIds: [local.id, remote.id], replyRounds: 3 },
+        agents: [local, remote],
+        remoteServerAddresses: { [remote.nodeId]: '192.168.1.20:9119' },
+      },
+      global: { stubs: { teleport: true } },
+    })
+
+    await wrapper.get('button[aria-label="设置远端夭夭"]').trigger('click')
+    const remoteName = wrapper.get<HTMLInputElement>('input[aria-label="群内名称"]')
+    await remoteName.setValue('群内审查员')
+    await wrapper.get('.save-agent').trigger('click')
+    expect(wrapper.emitted('updateAgent')?.at(-1)).toEqual([
+      remote.id,
+      { displayName: '群内审查员' },
+    ])
+    expect(wrapper.text()).toContain('192.168.1.20:9119')
+
+    await wrapper.get('button[aria-label="设置夭夭"]').trigger('click')
+    expect(wrapper.find('input[aria-label="群内名称"]').exists()).toBe(false)
+  })
+
+  it('uses the synced remote Agent name and marks its topic message', () => {
+    const remote = {
+      ...agent('agent-remote', 'reviewer', '群内审查员', false, false),
+      nodeId: '11111111-1111-4111-8111-111111111111',
+      nodeLabel: '工作室 Mac',
+    }
+    const message: GroupMessage = {
+      seq: 1,
+      id: 'message-1',
+      roomId: 'room-1',
+      senderKind: 'agent',
+      senderId: remote.id,
+      senderName: '旧名称',
+      rootMessageId: 'message-1',
+      content: '已完成',
+      reasoning: '',
+      toolState: [],
+      status: 'completed',
+      error: '',
+      createdAt: 1,
+      updatedAt: 1,
+    }
+
+    expect(groupMessageToUi(message, [remote])).toMatchObject({
+      author: '群内审查员',
+      isRemoteAgent: true,
+    })
+  })
 })