From 894566cdf3985b3a6497cd84fffde739c3c830c1 Mon Sep 17 00:00:00 2001 From: QuanCheng <915158214@qq.com> Date: Tue, 18 Aug 2026 09:20:10 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(workspace)=20agent=20display=20names?= =?UTF-8?q?=20=E2=80=94=20=E4=B8=AD=E8=8B=B1=E6=96=87=E5=90=8D=E7=A7=B0?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents keep their ASCII agent_name as the stable identity (mentions, routing, storage keys) and gain an optional display_name shown across the UI. Any script is allowed (Chinese, emoji); empty clears back to agent_name. Backend - workspace_members.display_name column + alembic migration 040 - PATCH member accepts display_name — trimmed, max 64 chars, rejected when it matches another member's agent_name (mention ambiguity) - discover and workspace responses carry the new field - LLM router prompt lists the display name as an alias so users can address agents by it; the output contract still uses the ASCII name Frontend - agentLabel() helper, displayName on WorkspaceAgent/NetworkAgent - all roster/chat/task/routine/thread surfaces render the label while API calls keep using agentName - mention picker filters by display name too and inserts the ASCII token; rendered mentions show the label Slack-style - inline display-name editor in the agent profile panel --- .../versions/040_add_member_display_name.py | 28 +++++++ workspace/backend/app/models.py | 3 + workspace/backend/app/mods/workspace_mod.py | 5 ++ workspace/backend/app/routers/network.py | 1 + workspace/backend/app/routers/workspaces.py | 35 ++++++++- workspace/backend/tests/test_workspaces.py | 63 +++++++++++++++ .../components/agents/agent-profile-panel.tsx | 76 ++++++++++++++++++- .../components/agents/agent-status-card.tsx | 3 +- .../frontend/components/chat/chat-input.tsx | 19 ++++- .../frontend/components/chat/chat-message.tsx | 14 +++- .../frontend/components/chat/chat-view.tsx | 9 ++- .../components/chat/intermediate-steps.tsx | 6 +- .../components/chat/markdown-content.tsx | 23 +++--- .../components/chat/thinking-message.tsx | 3 +- .../frontend/components/inbox/inbox-view.tsx | 6 +- .../frontend/components/layout/nav-agents.tsx | 6 +- .../frontend/components/layout/nav-rail.tsx | 8 +- .../components/layout/search-menu.tsx | 6 +- .../routines/create-routine-dialog.tsx | 3 +- .../components/routines/routines-view.tsx | 5 +- .../components/tasks/new-task-dialog.tsx | 3 +- .../frontend/components/tasks/tasks-view.tsx | 3 +- .../components/threads/new-thread-dialog.tsx | 3 +- .../components/threads/thread-list.tsx | 8 +- .../workflows/workflow-builder-dialog.tsx | 3 +- workspace/frontend/lib/api.ts | 3 +- workspace/frontend/lib/helpers.ts | 8 ++ workspace/frontend/lib/i18n/messages/en-US.ts | 3 + workspace/frontend/lib/i18n/messages/zh-CN.ts | 3 + workspace/frontend/lib/types.ts | 4 + 30 files changed, 317 insertions(+), 46 deletions(-) create mode 100644 workspace/backend/alembic/versions/040_add_member_display_name.py diff --git a/workspace/backend/alembic/versions/040_add_member_display_name.py b/workspace/backend/alembic/versions/040_add_member_display_name.py new file mode 100644 index 000000000..389da9531 --- /dev/null +++ b/workspace/backend/alembic/versions/040_add_member_display_name.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +"""Add workspace_members.display_name — user-set label for an agent. + +Revision ID: 040 +Revises: 039 +Create Date: 2026-08-18 + +Any script is allowed (Chinese, emoji, ...). agent_name remains the ASCII +identity used for @mentions, routing and storage keys; UIs fall back to it +when display_name is null. +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "040" +down_revision = "039" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("workspace_members", sa.Column("display_name", sa.Text(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("workspace_members", "display_name") diff --git a/workspace/backend/app/models.py b/workspace/backend/app/models.py index f7bd18ebc..b53577901 100644 --- a/workspace/backend/app/models.py +++ b/workspace/backend/app/models.py @@ -107,6 +107,9 @@ class WorkspaceMember(Base): workspace_id = Column(UUID(as_uuid=False), ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False) agent_name = Column(Text, nullable=False) + # Free-form label shown in UIs (any script, incl. CJK); agent_name stays + # the ASCII identity used for mentions, routing and storage keys. + display_name = Column(Text, nullable=True) role = Column(Text, default="member") # master | member | observer agent_type = Column(Text, nullable=True) # "claude", "openclaw", etc. server_host = Column(Text, nullable=True) # hostname/IP where agent runs diff --git a/workspace/backend/app/mods/workspace_mod.py b/workspace/backend/app/mods/workspace_mod.py index c67f8d75a..e320556f1 100644 --- a/workspace/backend/app/mods/workspace_mod.py +++ b/workspace/backend/app/mods/workspace_mod.py @@ -867,6 +867,11 @@ async def _route_with_llm( role = m.role if m else "member" desc = m.description if m and m.description else "" line = f" - {name} (role: {role})" + # Users may address an agent by its display name ("小明, 帮我看下") + # rather than its ASCII agent name — give the router the alias. The + # output contract stays next:. + if m and m.display_name and m.display_name != name: + line += f" (also known as: {m.display_name})" if desc: line += f" — {desc}" participant_lines.append(line) diff --git a/workspace/backend/app/routers/network.py b/workspace/backend/app/routers/network.py index 226589085..e6ec9b31e 100644 --- a/workspace/backend/app/routers/network.py +++ b/workspace/backend/app/routers/network.py @@ -401,6 +401,7 @@ def discover( status = "offline" agents.append({ "address": f"openagents:{m.agent_name}", + "display_name": m.display_name, "role": m.role, "status": status, "agent_type": m.agent_type, diff --git a/workspace/backend/app/routers/workspaces.py b/workspace/backend/app/routers/workspaces.py index 73e4ffc8d..77c9e555a 100644 --- a/workspace/backend/app/routers/workspaces.py +++ b/workspace/backend/app/routers/workspaces.py @@ -25,7 +25,7 @@ from fastapi import APIRouter, Depends, Header, Query from pydantic import BaseModel, Field -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.orm import Session, selectinload from app.config import config @@ -145,6 +145,7 @@ def _format_workspace(ws: Workspace, members: list, now: datetime) -> dict: status = "offline" agents.append({ "agentName": m.agent_name, + "displayName": m.display_name, "role": m.role, "agentType": m.agent_type, "status": status, @@ -563,6 +564,11 @@ class MemberUpdateRequest(BaseModel): description: Optional[str] = None role: Optional[str] = None enabled_skills: Optional[Dict[str, bool]] = None + # Display label, any script. Empty string clears it (falls back to agent_name). + display_name: Optional[str] = None + + +MAX_DISPLAY_NAME_LENGTH = 64 @router.patch("/{workspace_id}/members/{agent_name}") @@ -595,6 +601,32 @@ def update_member( if not member: return json_response(ResponseCode.NOT_FOUND, "Member not found") + if body.display_name is not None: + display_name = body.display_name.strip() + if not display_name: + member.display_name = None + else: + if len(display_name) > MAX_DISPLAY_NAME_LENGTH: + return json_response( + ResponseCode.BAD_REQUEST, + f"Display name must be at most {MAX_DISPLAY_NAME_LENGTH} characters", + ) + # A display name that matches another member's agent_name would make + # the @mention picker ambiguous (two entries reading the same but + # resolving to different agents) — reject it. + clash = db.execute( + select(WorkspaceMember.agent_name).where( + WorkspaceMember.workspace_id == workspace.id, + WorkspaceMember.agent_name != agent_name, + func.lower(WorkspaceMember.agent_name) == display_name.lower(), + ) + ).first() + if clash: + return json_response( + ResponseCode.BAD_REQUEST, + f"Display name conflicts with another member's agent name ('{clash[0]}')", + ) + member.display_name = display_name if body.description is not None: member.description = body.description if body.role is not None: @@ -609,6 +641,7 @@ def update_member( return success_response({ "agentName": member.agent_name, + "displayName": member.display_name, "description": member.description, "role": member.role, "enabledSkills": member.enabled_skills, diff --git a/workspace/backend/tests/test_workspaces.py b/workspace/backend/tests/test_workspaces.py index 7a7f87ecc..c63015e36 100644 --- a/workspace/backend/tests/test_workspaces.py +++ b/workspace/backend/tests/test_workspaces.py @@ -449,3 +449,66 @@ def test_remove_no_credentials(self, client, workspace): f"/v1/workspaces/{workspace['id']}/members/agent-alpha", ) assert resp.status_code == 401 + + +class TestMemberDisplayName: + """PATCH /v1/workspaces/{id}/members/{agent_name} — display_name.""" + + def _join(self, client, workspace, name): + resp = client.post("/v1/join", json={ + "agent_name": name, + "token": workspace["token"], + "network": workspace["id"], + }) + assert resp.status_code == 200 + + def _patch(self, client, workspace, name, display_name): + return client.patch( + f"/v1/workspaces/{workspace['id']}/members/{name}", + json={"display_name": display_name}, + headers={"X-Workspace-Token": workspace["token"]}, + ) + + def test_set_display_name_any_script(self, client, workspace): + """CJK / emoji display names are accepted and returned everywhere.""" + self._join(client, workspace, "agent-alpha") + resp = self._patch(client, workspace, "agent-alpha", "小明 🤖") + assert resp.status_code == 200 + assert resp.json()["data"]["displayName"] == "小明 🤖" + + disc = client.get("/v1/discover", params={"network": workspace["id"]}, + headers={"X-Workspace-Token": workspace["token"]}) + agents = {a["address"]: a for a in disc.json()["data"]["agents"]} + assert agents["openagents:agent-alpha"]["display_name"] == "小明 🤖" + + def test_clear_display_name_with_empty_string(self, client, workspace): + self._join(client, workspace, "agent-alpha") + self._patch(client, workspace, "agent-alpha", "小明") + resp = self._patch(client, workspace, "agent-alpha", " ") + assert resp.status_code == 200 + assert resp.json()["data"]["displayName"] is None + + def test_display_name_is_trimmed(self, client, workspace): + self._join(client, workspace, "agent-alpha") + resp = self._patch(client, workspace, "agent-alpha", " Ming ") + assert resp.status_code == 200 + assert resp.json()["data"]["displayName"] == "Ming" + + def test_display_name_too_long_rejected(self, client, workspace): + self._join(client, workspace, "agent-alpha") + resp = self._patch(client, workspace, "agent-alpha", "x" * 65) + assert resp.status_code == 400 + + def test_display_name_clashing_with_other_agent_name_rejected(self, client, workspace): + """A display name equal to another member's agent_name would make + the @mention picker ambiguous.""" + self._join(client, workspace, "agent-alpha") + self._join(client, workspace, "agent-beta") + resp = self._patch(client, workspace, "agent-alpha", "Agent-Beta") + assert resp.status_code == 400 + + def test_display_name_may_equal_own_agent_name(self, client, workspace): + self._join(client, workspace, "agent-alpha") + resp = self._patch(client, workspace, "agent-alpha", "agent-alpha") + assert resp.status_code == 200 + assert resp.json()["data"]["displayName"] == "agent-alpha" diff --git a/workspace/frontend/components/agents/agent-profile-panel.tsx b/workspace/frontend/components/agents/agent-profile-panel.tsx index 31bbfa4a1..9ddd27e9c 100644 --- a/workspace/frontend/components/agents/agent-profile-panel.tsx +++ b/workspace/frontend/components/agents/agent-profile-panel.tsx @@ -1,7 +1,7 @@ 'use client'; import { useState, useEffect, useCallback } from 'react'; -import { X, Copy, Check, Plus, Globe, Folder, Monitor, UserRoundCog, Cloud, Trash2, KeyRound, RefreshCw, Sparkles, ExternalLink } from 'lucide-react'; +import { X, Copy, Check, Plus, Globe, Folder, Monitor, UserRoundCog, Cloud, Trash2, KeyRound, RefreshCw, Sparkles, ExternalLink, Pencil } from 'lucide-react'; import { useLayout } from '@/components/layout/layout-context'; import { useWorkspace } from '@/lib/workspace-context'; import { useConfirm } from '@/components/ui/dialogs-provider'; @@ -9,6 +9,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'; import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'; import { workspaceApi } from '@/lib/api'; import { cn } from '@/lib/utils'; +import { agentLabel } from '@/lib/helpers'; import { toast } from 'sonner'; import type { CloudAgentConfig } from '@/lib/types'; import { useT } from '@/lib/i18n'; @@ -92,6 +93,40 @@ export function AgentProfilePanel() { useEffect(() => { setEditingKey(false); setNewApiKey(''); }, [agent?.agentName]); + // Display name — inline edit in the header. Any script is allowed (the + // ASCII agentName stays the @mention token); empty clears back to agentName. + const [editingName, setEditingName] = useState(false); + const [nameDraft, setNameDraft] = useState(''); + const [savingName, setSavingName] = useState(false); + + useEffect(() => { + setEditingName(false); + setNameDraft(agent?.displayName || ''); + }, [agent?.agentName, agent?.displayName]); + + const handleSaveDisplayName = useCallback(async () => { + if (!agent) return; + const trimmed = nameDraft.trim(); + if (trimmed === (agent.displayName || '')) { + setEditingName(false); + return; + } + setSavingName(true); + try { + await workspaceApi.updateMember(agent.agentName, { display_name: trimmed }); + await refreshWorkspace(); + setEditingName(false); + toast.success(t('agents.displayNameSaved')); + } catch (err) { + // Surface the server's reason (length cap, clash with another agent's name) + const raw = err instanceof Error ? err.message : ''; + const detail = raw.match(/"message"\s*:\s*"([^"]+)"/)?.[1]; + toast.error(detail || t('agents.displayNameSaveFailed')); + } finally { + setSavingName(false); + } + }, [agent, nameDraft, refreshWorkspace, t]); + // Description state — local draft + save const [description, setDescription] = useState(''); const [saving, setSaving] = useState(false); @@ -205,7 +240,44 @@ export function AgentProfilePanel() {
-

{agent.agentName}

+ {editingName ? ( +
+ setNameDraft(e.target.value)} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing) return; + if (e.key === 'Enter') handleSaveDisplayName(); + if (e.key === 'Escape') { setEditingName(false); setNameDraft(agent.displayName || ''); } + }} + placeholder={agent.agentName} + maxLength={64} + className="min-w-0 flex-1 rounded border bg-transparent px-1.5 py-0.5 text-[15px] font-semibold outline-none focus:ring-1 focus:ring-foreground/20" + autoFocus + /> + +
+ ) : ( +
+

{agentLabel(agent)}

+ +
+ )} + {agent.displayName && agent.displayName !== agent.agentName && ( +

@{agent.agentName}

+ )}
-

{agent.agentName}

+

{agentLabel(agent)}

{agent.agentType && {agent.agentType} · } {isOnline diff --git a/workspace/frontend/components/chat/chat-input.tsx b/workspace/frontend/components/chat/chat-input.tsx index d6930bf3a..8ac39429c 100644 --- a/workspace/frontend/components/chat/chat-input.tsx +++ b/workspace/frontend/components/chat/chat-input.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; import type { WorkspaceAgent, KnowledgeEntry } from '@/lib/types'; import { AgentAvatar } from '@/components/agents/agent-avatar'; +import { agentLabel } from '@/lib/helpers'; import { BookOpen } from 'lucide-react'; import { toast } from 'sonner'; import { useT } from '@/lib/i18n'; @@ -106,9 +107,14 @@ export function ChatInput({ onSend, disabled, className, agents = [], knowledge }; // Only suggest online agents — mentioning offline ones never resolves and - // just clutters the picker on long-lived workspaces. + // just clutters the picker on long-lived workspaces. Filter matches either + // the ASCII agent name or the user-set display name (e.g. typing "小" finds + // the agent labeled "小明"); the inserted mention is always the agent name. const filteredAgents = agents.filter( - (a) => a.status === 'online' && a.agentName.toLowerCase().includes(mentionFilter.toLowerCase()) + (a) => a.status === 'online' && ( + a.agentName.toLowerCase().includes(mentionFilter.toLowerCase()) || + (a.displayName || '').toLowerCase().includes(mentionFilter.toLowerCase()) + ) ); const filteredKnowledge = knowledge.filter( @@ -250,7 +256,9 @@ export function ChatInput({ onSend, disabled, className, agents = [], knowledge // Detect @mention trigger const cursorPos = textarea.selectionStart; const textBefore = value.slice(0, cursorPos); - const atMatch = textBefore.match(/@([\w:-]*)$/); + // [^\s@] (not \w) so typing a display name like "@小明" keeps the + // picker open while filtering; the inserted mention is still ASCII. + const atMatch = textBefore.match(/@([^\s@]*)$/); if (atMatch && (agents.length > 1 || knowledge.length > 0)) { setMentionFilter(atMatch[1]); setMentionIndex(0); @@ -368,7 +376,10 @@ export function ChatInput({ onSend, disabled, className, agents = [], knowledge }} > - {agent.agentName} + {agentLabel(agent)} + {agentLabel(agent) !== agent.agentName && ( + @{agent.agentName} + )} agents.map((a) => a.agentName), [agents]); + const agentLabels = useMemo(() => { + const labels: Record = {}; + for (const a of agents) { + if (a.displayName) labels[a.agentName] = agentLabel(a); + } + return labels; + }, [agents]); const agent = agents.find((a) => a.agentName === message.senderName); const rawAttachments = (message.metadata?.attachments as Record[]) || []; const attachments: Attachment[] = rawAttachments.map((a) => ({ @@ -192,7 +200,7 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C )}

- +
@@ -211,7 +219,7 @@ export const ChatMessage = memo(function ChatMessage({ message, agents = [] }: C
- {message.senderName} + {agent ? agentLabel(agent) : message.senderName} {agent && (
- + {/* Action bar — revealed on hover, as in ChatGPT */} diff --git a/workspace/frontend/components/chat/chat-view.tsx b/workspace/frontend/components/chat/chat-view.tsx index 37b8b67fe..7edd41025 100644 --- a/workspace/frontend/components/chat/chat-view.tsx +++ b/workspace/frontend/components/chat/chat-view.tsx @@ -27,6 +27,7 @@ import { useLayout } from '@/components/layout/layout-context'; import { DetailHeader } from '@/components/layout/app-header'; import { cn } from '@/lib/utils'; import { AgentAvatar } from '@/components/agents/agent-avatar'; +import { agentLabel } from '@/lib/helpers'; import { CreateRoutineDialog } from '@/components/routines/create-routine-dialog'; import { eventToMessage } from '@/lib/types'; import type { WorkspaceMessage } from '@/lib/types'; @@ -624,12 +625,12 @@ export function ChatView() { title={t('chat.manageThreadAgents')} > {sessionAgents.slice(0, 3).map((agent) => ( -
+
))} {sessionAgents.length > 3 && ( -
agent.agentName).join(', ')}> +
agentLabel(agent)).join(', ')}> +{sessionAgents.length - 3}
)} @@ -645,7 +646,7 @@ export function ChatView() { className="flex items-center gap-2 px-2 py-1.5 rounded-md group" > - {agent.agentName} + {agentLabel(agent)} {agent.status !== 'online' && ( {t('agentStatus.offline')} )} @@ -689,7 +690,7 @@ export function ChatView() { className="w-full flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-accent transition-colors" > - {agent.agentName} + {agentLabel(agent)} ))} diff --git a/workspace/frontend/components/chat/intermediate-steps.tsx b/workspace/frontend/components/chat/intermediate-steps.tsx index 51e80d438..49073b6fd 100644 --- a/workspace/frontend/components/chat/intermediate-steps.tsx +++ b/workspace/frontend/components/chat/intermediate-steps.tsx @@ -17,6 +17,7 @@ import { ListTodo, } from 'lucide-react'; import { AgentAvatar } from '@/components/agents/agent-avatar'; +import { agentLabel } from '@/lib/helpers'; import { WorkingIndicator } from './working-indicator'; import type { WorkspaceMessage, WorkspaceAgent } from '@/lib/types'; import { useT } from '@/lib/i18n'; @@ -339,7 +340,10 @@ export const IntermediateSteps = memo(function IntermediateSteps({ steps, agents
- {group.sender} + {(() => { + const a = agents?.find((x) => x.agentName === group.sender); + return a ? agentLabel(a) : group.sender; + })()}
)} diff --git a/workspace/frontend/components/chat/markdown-content.tsx b/workspace/frontend/components/chat/markdown-content.tsx index 4e363ad91..28f1cc0d9 100644 --- a/workspace/frontend/components/chat/markdown-content.tsx +++ b/workspace/frontend/components/chat/markdown-content.tsx @@ -16,10 +16,13 @@ const rehypePlugins = [rehypeHighlight]; interface MarkdownContentProps { content: string; agentNames: string[]; + /** agentName → display label; mentions render as @