Răsfoiți Sursa

改用Hermes Bots名称作为Agent名称

samien 2 luni în urmă
părinte
comite
112d94ffbe

+ 1 - 1
hermes-plugins/yaoyao/dashboard/group_plugin_api.py

@@ -928,7 +928,7 @@ def set_store_for_testing(store: object | None) -> None:
 
 
 def set_agent_name_resolver(resolver: Callable[[str], str]) -> None:
-    """Use the plugin's existing per-profile agentName setting."""
+    """Use the host-provided Hermes Bot display-name resolver."""
     global _agent_name_resolver
     _agent_name_resolver = resolver
 

+ 69 - 9
hermes-plugins/yaoyao/dashboard/plugin_api.py

@@ -44,6 +44,62 @@ import poller  # noqa: E402
 import voice_store  # noqa: E402
 
 
+def _profile_home(profile: Optional[str]) -> Path:
+    """Resolve the Hermes profile home without consulting YaoYao state."""
+    profile_name = str(profile or "default").strip() or "default"
+    try:
+        from hermes_constants import get_default_hermes_root  # type: ignore
+
+        root = Path(get_default_hermes_root())
+    except Exception:
+        configured = os.environ.get("HERMES_HOME", "").strip()
+        root = Path(configured) if configured else Path.home() / ".hermes"
+    if profile_name == "default":
+        return root
+    try:
+        from hermes_cli.profiles import get_profile_dir  # type: ignore
+
+        return Path(get_profile_dir(profile_name))
+    except Exception:
+        return root / "profiles" / profile_name
+
+
+def _hermes_bot_name(profile: Optional[str], profile_home: Optional[Path] = None) -> str:
+    """Read the Bot Mode title, then the core Hermes display name.
+
+    Agent identity no longer falls back to YaoYao's legacy agent_settings.json.
+    """
+    try:
+        import yaml  # type: ignore
+
+        path = (profile_home or _profile_home(profile)) / "profile.yaml"
+        raw = yaml.safe_load(path.read_text(encoding="utf-8")) if path.is_file() else {}
+        if not isinstance(raw, dict):
+            return ""
+        ui_meta = raw.get("ui_meta")
+        bot_meta = ui_meta.get("hermes-bots") if isinstance(ui_meta, dict) else None
+        candidates = [
+            bot_meta.get("title") if isinstance(bot_meta, dict) else None,
+            raw.get("display_name"),
+        ]
+        for candidate in candidates:
+            if not isinstance(candidate, str):
+                continue
+            name = " ".join(candidate.split())
+            if (
+                name
+                and len(name) <= store.MAX_AGENT_NAME_LENGTH
+                and not any(ord(character) < 32 or ord(character) == 127 for character in name)
+                and not store.is_reserved_mention_alias(name)
+            ):
+                return name
+    except (OSError, TypeError, ValueError):
+        pass
+    except Exception as error:
+        log.warning("Hermes Bot name load failed for %s: %s", profile, error)
+    return ""
+
+
 def _load_group_plugin_api():
     module_path = Path(_THIS_DIR) / "group_plugin_api.py"
     module_name = (
@@ -72,7 +128,7 @@ def _load_group_plugin_api():
 
 group_plugin_api = _load_group_plugin_api()
 group_plugin_api.set_agent_name_resolver(
-    lambda profile: str(store.load_agent_settings(profile).get("agentName") or "")
+    lambda profile: _hermes_bot_name(profile)
 )
 
 
@@ -219,19 +275,22 @@ def _to_file_library_item(it: dict) -> dict:
 def list_profiles():
     """Profiles with an existing state.db (i.e. ones the poller watches).
 
-    Returns ``[{name, label, isDefault}]``. Used by the dashboard's profile
-    dropdown. ``default`` is always first when present.
+    Returns ``[{name, label, botName, isDefault}]``. ``botName`` comes from
+    Hermes Bot Mode (then the core profile display name), never YaoYao's
+    legacy Agent-name setting. ``default`` is always first when present.
     """
     pairs = poller._discover_profiles()
     out = []
     for name, home in pairs:
-        agent_settings = store.load_agent_settings(name)
-        agent_name = agent_settings["agentName"]
+        bot_name = _hermes_bot_name(name, Path(home))
         out.append(
             {
                 "name": name,
-                "label": agent_name or ("默认" if name == "default" else name),
-                "agentName": agent_name,
+                "label": bot_name or ("默认" if name == "default" else name),
+                "botName": bot_name,
+                # Compatibility alias for older YaoYao clients. Its value is
+                # still Hermes-owned Bot metadata, not agent_settings.json.
+                "agentName": bot_name,
                 "isDefault": name == "default",
             }
         )
@@ -414,10 +473,11 @@ def rescan():
 
 
 # ===========================================================================
-# Agent display-name settings
+# Legacy Agent display-name settings
 #
 # The profile id stays the stable routing key. ``agentName`` is an optional,
-# per-profile presentation value used by Dashboard and Yaoyao clients.
+# per-profile presentation value retained for older clients. Group Agent
+# identity and current clients use Hermes Bot metadata instead.
 #
 #   GET /agent/settings?profile=<profile>
 #   PUT /agent/settings?profile=<profile>  <- {"agentName": "..."}

+ 17 - 4
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -1215,18 +1215,26 @@ class GroupSettingsContractTests(unittest.TestCase):
                 group_plugin_api._add_agent_command(explicit_add)["replyWithoutMention"]
             )
 
-    def test_plugin_loader_injects_existing_agent_name_setting(self) -> None:
+    def test_plugin_loader_uses_hermes_bot_name_not_legacy_agent_setting(self) -> None:
         probe = textwrap.dedent(
             """
             import json
+            import os
             from pathlib import Path
             import sys
             import types
 
+            hermes_home = Path(os.environ["HERMES_HOME"])
+            hermes_home.mkdir(parents=True, exist_ok=True)
+            (hermes_home / "profile.yaml").write_text(
+                "ui_meta:\\n  hermes-bots:\\n    title: Hermes 名称\\n",
+                encoding="utf-8",
+            )
             dashboard = Path(sys.argv[1])
             sys.path.insert(0, str(dashboard))
             poller = types.ModuleType("poller")
             poller.start = lambda: None
+            poller._discover_profiles = lambda: [("default", hermes_home)]
             sys.modules["poller"] = poller
             import plugin_api
             plugin_api.store.load_agent_settings = lambda profile: {
@@ -1244,7 +1252,10 @@ class GroupSettingsContractTests(unittest.TestCase):
                 "cwd": "",
                 "agents": [{"profile": "default"}],
             })
-            print(json.dumps(room, ensure_ascii=False))
+            print(json.dumps({
+                "room": room,
+                "profiles": plugin_api.list_profiles()["profiles"],
+            }, ensure_ascii=False))
             """
         )
         with tempfile.TemporaryDirectory() as hermes_home:
@@ -1257,8 +1268,10 @@ class GroupSettingsContractTests(unittest.TestCase):
                 text=True,
                 env=environment,
             )
-        room = json.loads(result.stdout.strip().splitlines()[-1])
-        self.assertEqual(room["agents"][0]["displayName"], "现有名称")
+        payload = json.loads(result.stdout.strip().splitlines()[-1])
+        self.assertEqual(payload["room"]["agents"][0]["displayName"], "Hermes 名称")
+        self.assertEqual(payload["profiles"][0]["botName"], "Hermes 名称")
+        self.assertEqual(payload["profiles"][0]["agentName"], "Hermes 名称")
 
     def test_cascade_freezes_reply_modes_across_rename_and_setting_change(self) -> None:
         with tempfile.TemporaryDirectory() as directory:

+ 19 - 5
src/server/routes.ts

@@ -123,15 +123,15 @@ function normalizedProfiles(value: JsonObject): unknown[] {
   })
 }
 
-function profilesWithAgentNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
+function profilesWithHermesBotNames(profiles: unknown[], pluginProfiles: JsonObject | undefined): unknown[] {
   const items = Array.isArray(pluginProfiles?.profiles) ? pluginProfiles.profiles : []
   const names = new Map<string, string>()
   for (const entry of items) {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue
     const profile = entry as JsonObject
     const name = typeof profile.name === 'string' ? profile.name : ''
-    const agentName = typeof profile.agentName === 'string' ? profile.agentName.trim() : ''
-    if (name && agentName) names.set(name, agentName)
+    const botName = typeof profile.botName === 'string' ? profile.botName.trim() : ''
+    if (name && botName) names.set(name, botName)
   }
   return profiles.map(entry => {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return entry
@@ -310,7 +310,7 @@ async function bootstrap(
     ? parseJson(identityResponse)
     : { user_id: 'local', display_name: '本机 Hermes', provider: 'local' }
   const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
-  const profiles = profilesWithAgentNames(
+  const profiles = profilesWithHermesBotNames(
     normalizedProfiles(parseJson(profilesResponse)),
     pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300 ? parseJson(pluginProfilesResponse) : undefined,
   )
@@ -850,7 +850,21 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
   })
 
   router.get('/api/app/profiles', async (ctx) => {
-    await proxy(ctx, dependencies.upstream, '/api/profiles')
+    await withJar(ctx, async (jar) => {
+      const profilesResponse = await dependencies.upstream.request('/api/profiles', jar)
+      if (profilesResponse.status < 200 || profilesResponse.status >= 300) {
+        sendUpstreamResponse(ctx, profilesResponse, jar)
+        return
+      }
+      const pluginProfilesResponse = await dependencies.upstream.request('/api/plugins/yaoyao/profiles', jar)
+      const profiles = profilesWithHermesBotNames(
+        normalizedProfiles(parseJson(profilesResponse)),
+        pluginProfilesResponse.status >= 200 && pluginProfilesResponse.status < 300
+          ? parseJson(pluginProfilesResponse)
+          : undefined,
+      )
+      json(ctx, 200, { profiles })
+    })
   })
   router.get('/api/app/models', async (ctx) => {
     const search = searchFrom(ctx, ['profile'])

+ 2 - 2
tests/fixtures/fake-hermes.mjs

@@ -115,8 +115,8 @@ const server = createServer(async (request, response) => {
   if (url.pathname === '/api/auth/ws-ticket' && request.method === 'POST') return json(response, 200, { ticket: 'fake-ticket' })
   if (url.pathname === '/api/profiles') return json(response, 200, { profiles })
   if (url.pathname === '/api/plugins/yaoyao/profiles') return json(response, 200, { profiles: [
-    { name: 'yaoyao', label: '夭夭', agentName: '夭夭', isDefault: true },
-    { name: 'yaoer', label: '瑶儿', agentName: '瑶儿', isDefault: false },
+    { name: 'yaoyao', label: '夭夭', botName: '夭夭', agentName: '旧夭夭名称', isDefault: true },
+    { name: 'yaoer', label: '瑶儿', botName: '瑶儿', agentName: '旧瑶儿名称', isDefault: false },
   ] })
   if (url.pathname === '/api/model/options') return json(response, 200, { model: 'gpt-5.6', provider: 'openai', providers: [{ slug: 'openai', name: 'OpenAI', models: ['gpt-5.6', 'gpt-5.5'] }] })
   if (url.pathname === '/api/profiles/sessions' || url.pathname === '/api/sessions') {

+ 19 - 2
tests/server/app.test.ts

@@ -79,7 +79,7 @@ function fakeGateway(records: RecordedRequest[]): typeof fetch {
       case '/api/profiles':
         return jsonResponse({ profiles: [{ name: 'default', is_default: true }] })
       case '/api/plugins/yaoyao/profiles':
-        return jsonResponse({ profiles: [{ name: 'default', agentName: '夭夭', isDefault: true }] })
+        return jsonResponse({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称', isDefault: true }] })
       case '/api/auth/providers':
         return jsonResponse({ providers: [{ name: 'basic', supports_password: true }] })
       case '/auth/password-login':
@@ -180,7 +180,7 @@ describe('8800 BFF', () => {
       authRequired: true,
       authenticated: true,
       user: { user_id: 'user-1' },
-      profiles: [{ name: 'default', agentName: '夭夭' }],
+      profiles: [{ name: 'default', agentName: '竹儿' }],
       groupUploadsEnabled: true,
     })
     expect(response.body.csrfToken).toEqual(expect.any(String))
@@ -192,6 +192,23 @@ describe('8800 BFF', () => {
     expect(response.headers['content-security-policy']).toContain("default-src 'self'")
   })
 
+  it('refreshes profiles from Hermes Bot names and ignores legacy plugin names', async () => {
+    const records: RecordedRequest[] = []
+    const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway(records) })
+    runtimes.push(runtime)
+    const response = await request(runtime.app.callback())
+      .get('/api/app/profiles')
+      .set('Host', '127.0.0.1:8800')
+      .expect(200)
+    expect(records.map(entry => entry.path)).toEqual([
+      '/api/profiles', '/api/plugins/yaoyao/profiles',
+    ])
+    expect(response.body.profiles).toEqual([
+      { name: 'default', is_default: true, agentName: '竹儿' },
+    ])
+    expect(JSON.stringify(response.body)).not.toContain('旧插件名称')
+  })
+
   it('omits opener isolation on plaintext LAN origins but keeps it on loopback', async () => {
     const runtime = createApplication({ config: makeConfig(), fetchImpl: fakeGateway([]) })
     runtimes.push(runtime)

+ 1 - 1
tests/server/pairing.test.ts

@@ -126,7 +126,7 @@ describe('Hermes node pairing', () => {
         return new Response(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }), { headers: responseHeaders })
       }
       if (url.pathname === '/api/plugins/yaoyao/profiles') {
-        return new Response(JSON.stringify({ profiles: [{ name: 'default', agentName: '夭夭' }] }), { headers: responseHeaders })
+        return new Response(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }), { headers: responseHeaders })
       }
       if (url.pathname === '/api/auth/providers') {
         return new Response(JSON.stringify({

+ 1 - 1
tests/server/websocket.test.ts

@@ -73,7 +73,7 @@ describe('WebSocket relay', () => {
         pairedCookie = request.headers.cookie ?? pairedCookie
         response.end(JSON.stringify({ profiles: [{ name: 'default', is_default: true }] }))
       } else if (request.url === '/api/plugins/yaoyao/profiles') {
-        response.end(JSON.stringify({ profiles: [{ name: 'default', agentName: '夭夭' }] }))
+        response.end(JSON.stringify({ profiles: [{ name: 'default', botName: '竹儿', agentName: '旧插件名称' }] }))
       } else if (request.url === '/api/auth/providers') {
         response.end(JSON.stringify({ providers: [{ name: 'basic', supports_password: true }] }))
       } else if (request.url === '/auth/password-login' && request.method === 'POST') {