Sfoglia il codice sorgente

完善远程Agent身份与文件代理

LSamien 2 mesi fa
parent
commit
8e46d32765

+ 5 - 5
README.md

@@ -26,7 +26,7 @@ iOS 默认连接 `http://主机:8800` 并使用 8800 用户登录;已有 9119
 插件安装接口安装。默认源为
 `https://git.samien.cn/samien/hermes-yaoyao.git#hermes-plugins/yaoyao`。
 
-当前发布版本:**Git `v0.2.9` / 夭夭 Web `0.2.9` / Hermes Dashboard 插件 `1.7.2`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
+当前发布版本:**Git `v0.2.9` / 夭夭 Web `0.2.9` / Hermes Dashboard 插件 `1.7.3`**。版本组合由仓库根目录的 `release.json` 唯一声明,并由 `npm run release:verify` 校验。
 
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
@@ -171,11 +171,11 @@ npm run dev
 
 ## 手机扫码与跨节点 Agent
 
-插件 `1.7.2` 与当前 Web 服务共同提供 Hermes 节点配对。每台参与设备都需要安装相同版本的夭夭插件并运行 Web 服务:
+插件 `1.7.3` 与当前 Web 服务共同提供 Hermes 节点配对。每台参与设备都需要安装相同版本的夭夭插件并运行 Web 服务:
 
-1. 在目标 Hermes 的夭夭 Web 左下角打开“手机与节点”。
-2. 输入一次 Hermes 密码并生成两分钟有效、只能使用一次的二维码;密码只用于创建独立设备会话,不会保存。
-3. 在夭夭 iOS 的“设置 → 服务器与账号”中选择“扫描 Hermes 节点”。
+1. 在目标 Hermes 的夭夭 Web 左下角打开“手机登录与节点”。
+2. 生成两分钟有效、只能使用一次的“子节点配对二维码”。
+3. 在夭夭 iOS 的“设置 → 远程节点”中扫描;节点的 Agent 名称、头像和文件访问权限会随配对一起注册到当前父服务器。
 4. 扫描后,该节点的 Bots、普通聊天历史和 Agent Profile 会按节点身份加入手机;同名 Profile 使用 `nodeId + profile` 区分。
 
 二维码不包含账号密码、长期 Token 或 Dashboard Cookie。Web 服务会签发可撤销的设备 Token,并把代理所需的 Dashboard Cookie 加密保存在 `HERMES_YAOYAO_HOME`。iOS Token 按服务器账号保存在系统 Keychain。忘记 iOS 中的节点或在 Web 中撤销设备都会终止后续访问。

+ 3 - 3
docs/agent-install.md

@@ -8,7 +8,7 @@
 | --- | --- | --- |
 | Git 发布标签 | `v0.2.9` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
 | 夭夭 Web | `0.2.9` | `release.json` 与 `package.json` 的 `version` |
-| Hermes Dashboard 插件 | `1.7.2` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
+| Hermes Dashboard 插件 | `1.7.3` | `hermes-plugins/yaoyao/dashboard/manifest.json` 的 `version` |
 
 以下步骤以仓库根目录为工作目录。不要使用浮动分支替代发布标签。
 
@@ -89,7 +89,7 @@ git checkout "$RELEASE_VERSION"
 
 test "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
 test "$(node -p \"require('./package.json').version\")" = "0.2.9"
-test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.2"
+test "$(node -e \"console.log(require('./hermes-plugins/yaoyao/dashboard/manifest.json').version)\")" = "1.7.3"
 npm run release:verify
 ```
 
@@ -119,7 +119,7 @@ test -f "$PLUGIN_DIR/manifest.json"
 test -f "$PLUGIN_DIR/plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/dist/index.js"
-test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/manifest.json")" = "1.7.2"
+test "$(node -e \"console.log(require(process.argv[1]).version)\" "$PLUGIN_DIR/manifest.json")" = "1.7.3"
 ```
 
 `rsync --delete` 的范围严格限于 `$PLUGIN_DIR`,不会删除其他 Hermes 插件或用户数据。若目标目录已有额外的本机定制文件,应先将其移出目录或改用不带 `--delete` 的同步命令。

+ 17 - 0
hermes-plugins/yaoyao/dashboard/group_node_registry.py

@@ -5,6 +5,7 @@ from __future__ import annotations
 import json
 import os
 from pathlib import Path
+import re
 import tempfile
 import threading
 import time
@@ -254,14 +255,30 @@ class PairedNodeRegistry:
             seen.add(name)
             display_name = raw.get("displayName", "")
             model = raw.get("model", "")
+            avatar = raw.get("avatar", "")
+            color = raw.get("color", "")
             if not isinstance(display_name, str) or len(display_name) > 100:
                 raise ValueError("profiles.displayName is invalid")
             if not isinstance(model, str) or len(model) > 4096:
                 raise ValueError("profiles.model is invalid")
+            if not isinstance(avatar, str) or len(avatar) > 2_800_000:
+                raise ValueError("profiles.avatar is invalid")
+            if avatar and not re.fullmatch(
+                r"data:image/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}",
+                avatar,
+                re.IGNORECASE,
+            ):
+                raise ValueError("profiles.avatar is invalid")
+            if not isinstance(color, str) or (
+                color and re.fullmatch(r"#[0-9A-Fa-f]{6}", color) is None
+            ):
+                raise ValueError("profiles.color is invalid")
             result.append({
                 "name": name,
                 "displayName": display_name.strip(),
                 "model": model.strip(),
+                **({"avatar": avatar} if avatar else {}),
+                **({"color": color} if color else {}),
             })
         return result
 

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

@@ -7,6 +7,7 @@ import base64
 import hashlib
 import importlib
 import inspect
+import json
 import logging
 import os
 import re
@@ -19,12 +20,15 @@ from importlib.machinery import ModuleSpec
 from pathlib import Path
 from types import ModuleType
 from typing import Annotated, Any
+from urllib.error import HTTPError, URLError
+from urllib.parse import urlencode
+from urllib.request import Request as URLRequest, build_opener, ProxyHandler
 from uuid import UUID, uuid4
 
 from fastapi import APIRouter, File, Query, Request, UploadFile, WebSocket
 from fastapi import Path as APIPath
 from fastapi.exceptions import RequestValidationError
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, StreamingResponse
 from fastapi.routing import APIRoute
 
 logger = logging.getLogger(__name__)
@@ -1406,6 +1410,52 @@ async def list_paired_nodes() -> dict[str, object]:
     return {"items": nodes}
 
 
+async def refresh_paired_node_identities() -> dict[str, object]:
+    nodes = await _node_registry_call("list")
+    refreshed: list[dict[str, object]] = []
+    failed: list[str] = []
+    opener = build_opener(ProxyHandler({}))
+    for public in nodes:
+        node_id = public.get("nodeId") if isinstance(public, dict) else None
+        if not isinstance(node_id, str):
+            continue
+        try:
+            node = await _node_registry_call("get", node_id)
+            server_url = node.get("serverUrl")
+            access_token = node.get("accessToken")
+            if not isinstance(server_url, str) or not isinstance(access_token, str):
+                raise ValueError("invalid node route")
+            remote_request = URLRequest(
+                server_url.rstrip("/") + "/api/profile-identities",
+                method="GET",
+                headers={
+                    "Accept": "application/json",
+                    "Authorization": f"Bearer {access_token}",
+                },
+            )
+
+            def fetch() -> object:
+                with opener.open(remote_request, timeout=35) as response:
+                    raw = response.read(32 * 1024 * 1024 + 1)
+                if len(raw) > 32 * 1024 * 1024:
+                    raise ValueError("identity response too large")
+                return json.loads(raw)
+
+            payload = await asyncio.to_thread(fetch)
+            profiles = payload.get("profiles") if isinstance(payload, dict) else None
+            refreshed.append(await _node_registry_call("register", {
+                "nodeId": node_id,
+                "name": node["name"],
+                "serverUrl": server_url,
+                "fingerprint": node["fingerprint"],
+                "accessToken": access_token,
+                "profiles": profiles,
+            }))
+        except Exception:
+            failed.append(node_id)
+    return {"items": refreshed, "failedNodeIds": failed}
+
+
 async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
     removed = await _node_registry_call("revoke", str(node_id))
     if not removed:
@@ -1413,6 +1463,69 @@ async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
     return {"ok": True}
 
 
+async def download_paired_node_file(
+    node_id: UUID,
+    request: Request,
+    path: Annotated[str, Query(min_length=1, max_length=8192)],
+) -> object:
+    try:
+        node = await _node_registry_call("get", str(node_id))
+    except Exception as error:
+        raise GroupInvalidRequest("Paired Hermes node was not found") from error
+    server_url = node.get("serverUrl")
+    access_token = node.get("accessToken")
+    if not isinstance(server_url, str) or not isinstance(access_token, str):
+        raise GroupInvalidRequest("Paired Hermes node route is invalid")
+    target = server_url.rstrip("/") + "/api/node-files?" + urlencode({"path": path})
+    headers = {
+        "Accept": "application/octet-stream",
+        "Authorization": f"Bearer {access_token}",
+    }
+    requested_range = request.headers.get("range", "")
+    if requested_range and re.fullmatch(r"bytes=\d*-\d*", requested_range):
+        headers["Range"] = requested_range
+    remote_request = URLRequest(target, method="GET", headers=headers)
+    opener = build_opener(ProxyHandler({}))
+    try:
+        response = await asyncio.to_thread(opener.open, remote_request, timeout=60)
+    except HTTPError as error:
+        status = error.code if 400 <= error.code <= 599 else 502
+        error.close()
+        return JSONResponse(
+            status_code=status,
+            content={"error": "Remote node file request was rejected"},
+        )
+    except (URLError, TimeoutError, OSError) as error:
+        raise GroupInvalidRequest("Remote node file is unavailable") from error
+
+    def body_stream():
+        try:
+            while True:
+                chunk = response.read(256 * 1024)
+                if not chunk:
+                    break
+                yield chunk
+        finally:
+            response.close()
+
+    outgoing_headers = {"Cache-Control": "private, no-store"}
+    for source, target_name in (
+        ("Content-Length", "Content-Length"),
+        ("Content-Range", "Content-Range"),
+        ("Accept-Ranges", "Accept-Ranges"),
+        ("Content-Disposition", "Content-Disposition"),
+    ):
+        value = response.headers.get(source)
+        if value and "\r" not in value and "\n" not in value:
+            outgoing_headers[target_name] = value
+    return StreamingResponse(
+        body_stream(),
+        status_code=getattr(response, "status", 200),
+        media_type=response.headers.get("Content-Type", "application/octet-stream"),
+        headers=outgoing_headers,
+    )
+
+
 async def node_worker_submit_prompt(
     runtime_session_id: str,
     payload: NodeWorkerPromptRequest,
@@ -1598,9 +1711,17 @@ def _build_router() -> APIRouter:
     )
     built.add_api_route("/nodes", register_paired_node, methods=["POST"])
     built.add_api_route("/nodes", list_paired_nodes, methods=["GET"])
+    built.add_api_route(
+        "/nodes/refresh-identities",
+        refresh_paired_node_identities,
+        methods=["POST"],
+    )
     built.add_api_route(
         "/nodes/{node_id}", revoke_paired_node, methods=["DELETE"]
     )
+    built.add_api_route(
+        "/nodes/{node_id}/files", download_paired_node_file, methods=["GET"]
+    )
     built.add_api_route(
         "/node-worker/sessions", node_worker_open_session, methods=["POST"]
     )

+ 1 - 1
hermes-plugins/yaoyao/dashboard/manifest.json

@@ -3,7 +3,7 @@
   "label": "夭夭",
   "description": "文件归档、Agent 名称、语音设置与多设备群聊",
   "icon": "FolderOpen",
-  "version": "1.7.2",
+  "version": "1.7.3",
   "tab": { "path": "/yaoyao-files", "position": "after:files" },
   "entry": "dist/index.js",
   "css": "dist/style.v1.4.0.css",

+ 11 - 0
hermes-plugins/yaoyao/dashboard/tests/test_group_settings_contract.py

@@ -140,8 +140,19 @@ class GroupSettingsContractTests(unittest.TestCase):
                 "serverUrl": "https://remote.example/node/11111111-1111-4111-8111-111111111111",
                 "fingerprint": "fingerprint",
                 "accessToken": "top-secret-token",
+                "profiles": [{
+                    "name": "default",
+                    "displayName": "远端 Agent",
+                    "model": "provider/model",
+                    "avatar": "data:image/png;base64,AAAA",
+                    "color": "#336699",
+                }],
             })
             self.assertNotIn("accessToken", registered)
+            self.assertEqual(
+                registered["profiles"][0]["avatar"],
+                "data:image/png;base64,AAAA",
+            )
             persisted = (Path(directory) / "paired-group-nodes.json").read_text()
             self.assertNotIn("top-secret-token", persisted)
             self.assertEqual(registry.get(node_id)["accessToken"], "top-secret-token")

+ 1 - 1
hermes-plugins/yaoyao/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao
-version: "1.7.2"
+version: "1.7.3"
 description: "YaoYao Dashboard extension for files, Agent identity, voice, and multi-device teams"
 author: samien

+ 1 - 1
release.json

@@ -2,6 +2,6 @@
   "schemaVersion": 1,
   "releaseVersion": "0.2.9",
   "webVersion": "0.2.9",
-  "pluginVersion": "1.7.2",
+  "pluginVersion": "1.7.3",
   "gitTag": "v0.2.9"
 }

+ 13 - 5
src/client/api/groups.ts

@@ -32,16 +32,24 @@ export async function getGroupNodes(): Promise<GroupNode[]> {
       profiles: values(node.profiles).flatMap(profileValue => {
         const profile = record(profileValue)
         const name = String(profile.name ?? '')
-        return name ? [{
-          name,
-          displayName: String(profile.displayName ?? profile.display_name ?? name),
-          model: String(profile.model ?? ''),
-        }] : []
+          return name ? [{
+            name,
+            displayName: String(profile.displayName ?? profile.display_name ?? name),
+            model: String(profile.model ?? ''),
+            avatar: typeof profile.avatar === 'string' ? profile.avatar : undefined,
+            color: typeof profile.color === 'string' ? profile.color : undefined,
+          }] : []
       }),
     }]
   })
 }
 
+export async function refreshGroupNodeIdentities(): Promise<void> {
+  await apiRequest(`${BASE}/nodes/refresh-identities`, {
+    method: 'POST', body: {},
+  })
+}
+
 export async function getGroupRooms(cursor?: string, limit = 50, archived = false): Promise<GroupRoomPage> {
   const payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)), archived })))
   const source = record(payload)

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

@@ -238,6 +238,9 @@ export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = [
   const sender = message.senderKind === 'agent'
     ? agents.find(agent => agent.id === message.senderId)
     : undefined
+  const content = sender && sender.nodeId !== 'local'
+    ? rewriteRemoteNodeFiles(message.content, sender.nodeId)
+    : message.content
   return {
     id: message.id,
     role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
@@ -246,8 +249,12 @@ export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = [
     // Only local agents share this Web client's Desktop Bots identity map.
     // Remote nodes keep their own identity and intentionally do not collide by
     // profile slug with a local agent.
-    profile: sender?.nodeId === 'local' ? sender.profile : undefined,
-    content: message.content,
+    profile: sender
+      ? sender.nodeId === 'local'
+        ? sender.profile
+        : `node:${sender.nodeId}:${sender.profile}`
+      : undefined,
+    content,
     reasoning: message.reasoning,
     createdAt: message.createdAt < 10_000_000_000 ? message.createdAt * 1000 : message.createdAt,
     status: message.status === 'completed' ? 'settled' : message.status === 'queued' ? 'pending' : message.status === 'streaming' ? 'streaming' : message.status === 'failed' ? 'failed' : message.status === 'unknown' ? 'unknown-receipt' : 'settled',
@@ -257,6 +264,16 @@ export function groupMessageToUi(message: GroupMessage, agents: GroupAgent[] = [
   }
 }
 
+export function rewriteRemoteNodeFiles(content: string, nodeId: string): string {
+  if (!/^[0-9a-f-]{36}$/i.test(nodeId)) return content
+  return content.replace(
+    /(!?\[[^\]]*\]\()<?(?:file:\/\/)?(\/(?:Users|private|var|tmp)\/[^)>\n]+)>?(\))/g,
+    (_match, prefix: string, path: string, suffix: string) => (
+      `${prefix}/api/plugins/yaoyao/v1/nodes/${encodeURIComponent(nodeId)}/files?path=${encodeURIComponent(path)}${suffix}`
+    ),
+  )
+}
+
 export function groupInteraction(interaction?: GroupInteraction): UiInteraction | null {
   if (!interaction || interaction.status !== 'pending') return null
   const payload = record(interaction.payload)

+ 8 - 4
src/client/stores/groups.ts

@@ -7,7 +7,7 @@ import type {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
-  getGroupMessages, getGroupNodes, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead,
+  getGroupMessages, getGroupNodes, getGroupRoom, getGroupRooms, getGroupTopics, getPinnedGroupTopics, interruptGroupAgent as interruptApi, markGroupTopicRead, refreshGroupNodeIdentities,
   removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
@@ -145,6 +145,9 @@ export const useGroupsStore = defineStore('groups', () => {
       nodes.value = []
       return
     }
+    if (auth.user?.role === 'admin') {
+      try { await refreshGroupNodeIdentities() } catch { /* Keep last known node roster. */ }
+    }
     nodes.value = await getGroupNodes()
   }
 
@@ -224,9 +227,10 @@ export const useGroupsStore = defineStore('groups', () => {
           throw new Error(`团队协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
         }
         const loadedRooms = await loadRooms()
-        nodes.value = anchor.features?.includes('nodeRegistry')
-          ? await getGroupNodes()
-          : []
+        if (anchor.features?.includes('nodeRegistry') && auth.user?.role === 'admin') {
+          try { await refreshGroupNodeIdentities() } catch { /* Older plugins remain readable. */ }
+        }
+        nodes.value = anchor.features?.includes('nodeRegistry') ? await getGroupNodes() : []
         if (expectedGeneration !== generation) return
         await loadPinnedTopics()
         if (expectedGeneration !== generation) return

+ 14 - 7
src/client/views/GroupsView.vue

@@ -155,12 +155,18 @@ const messages = computed(() => groups.messages.map(message => groupMessageToUi(
 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(() => displayAgents.value.map(agentToUi))
-const agentAvatars = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
-  profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []
-)))
-const agentAvatarsByName = computed(() => Object.fromEntries(auth.profiles.flatMap(profile =>
-  profile.agentAvatar ? [[profile.agentName || profile.displayName || profile.name, profile.agentAvatar] as const] : []
-)))
+const agentAvatars = computed(() => Object.fromEntries([
+  ...auth.profiles.flatMap(profile =>
+    profile.agentAvatar ? [[profile.name, profile.agentAvatar] as const] : []),
+  ...groups.nodes.flatMap(node => node.profiles.flatMap(profile =>
+    profile.avatar ? [[`node:${node.nodeId}:${profile.name}`, profile.avatar] as const] : [])),
+]))
+const agentAvatarsByName = computed(() => Object.fromEntries([
+  ...auth.profiles.flatMap(profile =>
+    profile.agentAvatar ? [[profile.agentName || profile.displayName || profile.name, profile.agentAvatar] as const] : []),
+  ...groups.nodes.flatMap(node => node.profiles.flatMap(profile =>
+    profile.avatar ? [[profile.displayName || profile.name, profile.avatar] as const] : [])),
+]))
 function serverAddress(value: string): string {
   try { return new URL(value).host }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
@@ -188,6 +194,7 @@ const profiles = computed<GroupProfileOption[]>(() => [
     displayName: profile.displayName || profile.name,
     nodeId: node.nodeId,
     nodeLabel: node.name,
+    avatar: profile.avatar,
   }))),
 ])
 const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent =>
@@ -712,7 +719,7 @@ watch(() => auth.activeProfile?.name, profile => { if (profile) restoreShowThink
         <template #header-actions>
           <div class="group-header-actions">
             <span v-if="hostAgent" class="group-host-chip" :title="`用户未明确 @ 时由管理员 ${hostAgent.displayName || hostAgent.profile} 负责回应`">管理员 {{ hostAgent.displayName || hostAgent.profile }}</span>
-            <div class="group-avatars" aria-label="团队成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agent.nodeId === 'local' ? agentAvatars[agent.profile || ''] || '' : ''" :size="24" :title="agent.isHost ? `${agent.name} · 管理员` : agent.name" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
+            <div class="group-avatars" aria-label="团队成员"><AgentAvatar v-for="agent in agents.slice(0, 4)" :key="agent.id" :name="agent.name" :avatar="agentAvatars[agent.nodeId === 'local' ? agent.profile || '' : `node:${agent.nodeId}:${agent.profile}`] || ''" :size="24" :title="agent.isHost ? `${agent.name} · 管理员` : agent.name" /><em v-if="agents.length > 4">+{{ agents.length - 4 }}</em></div>
             <button class="icon-button" type="button" title="管理团队" aria-label="管理团队" :disabled="!groups.selectedRoom" @click="openSelectedRoomManager"><AppIcon name="dots" /></button>
           </div>
         </template>

+ 7 - 0
src/server/app.ts

@@ -12,6 +12,7 @@ import { RealtimeLeaseStore } from './leases.js'
 import { NodePairingStore } from './pairing.js'
 import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
+import { UpstreamProfileIdentityService } from './profileIdentities.js'
 import { createApiRouter } from './routes.js'
 import {
   applySecurityHeaders,
@@ -35,6 +36,7 @@ export interface ApplicationOptions {
   auth?: LocalAuthStore
   upstreamSession?: UpstreamServiceSession
   accountPairings?: AccountLoginPairingStore
+  profileIdentities?: UpstreamProfileIdentityService
 }
 
 export interface ApplicationRuntime {
@@ -49,6 +51,7 @@ export interface ApplicationRuntime {
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
+  profileIdentities: UpstreamProfileIdentityService
   close(): void
 }
 
@@ -101,6 +104,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
   )
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
+  const profileIdentities = options.profileIdentities
+    ?? new UpstreamProfileIdentityService(config, upstreamSession)
   try {
     uploads.cleanupUncommitted()
   } catch {
@@ -213,6 +218,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     auth,
     upstreamSession,
     accountPairings,
+    profileIdentities,
   })
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
@@ -238,6 +244,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     auth,
     upstreamSession,
     accountPairings,
+    profileIdentities,
     close: () => uploads.close(),
   }
 }

+ 136 - 0
src/server/profileIdentities.ts

@@ -0,0 +1,136 @@
+import WebSocket from 'ws'
+import type { ServerConfig } from './config.js'
+import { HttpError } from './errors.js'
+import type { UpstreamServiceSession } from './localAuth.js'
+
+export interface RemoteProfileIdentity {
+  name: string
+  displayName: string
+  model: string
+  color?: string
+  avatar?: string
+}
+
+type JsonObject = Record<string, unknown>
+
+function object(value: unknown): JsonObject {
+  return value && typeof value === 'object' && !Array.isArray(value)
+    ? value as JsonObject : {}
+}
+
+export class UpstreamProfileIdentityService {
+  constructor(
+    readonly config: ServerConfig,
+    readonly upstream: UpstreamServiceSession,
+  ) {}
+
+  async load(): Promise<RemoteProfileIdentity[]> {
+    const ticketResponse = await this.upstream.request('/api/auth/ws-ticket', {
+      method: 'POST',
+    })
+    if (ticketResponse.status < 200 || ticketResponse.status >= 300) {
+      throw new HttpError(502, 'Hermes 未签发身份读取凭据', 'profile_identity_ticket_failed')
+    }
+    let ticket = ''
+    try { ticket = String(JSON.parse(ticketResponse.body.toString('utf8')).ticket ?? '') } catch { /* handled below */ }
+    if (!ticket) throw new HttpError(502, 'Hermes 返回了空身份读取凭据', 'profile_identity_ticket_failed')
+
+    const url = new URL(this.config.upstream)
+    url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+    const prefix = this.config.upstream.pathname === '/'
+      ? '' : this.config.upstream.pathname.replace(/\/$/, '')
+    url.pathname = `${prefix}/api/ws`
+    url.search = ''
+    url.searchParams.set('ticket', ticket)
+
+    return new Promise<RemoteProfileIdentity[]>((resolve, reject) => {
+      const socket = new WebSocket(url, {
+        headers: { Origin: this.config.upstream.origin },
+        handshakeTimeout: 15_000,
+        maxPayload: 36 * 1_024 * 1_024,
+      })
+      const identities = new Map<string, RemoteProfileIdentity>()
+      const avatarRequests = new Map<string, string>()
+      let requestedProfiles = false
+      let finished = false
+      const timeout = setTimeout(() => finishError(new Error('profile identity request timed out')), 30_000)
+      timeout.unref()
+
+      const cleanup = () => {
+        clearTimeout(timeout)
+        if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) socket.close()
+      }
+      const finish = () => {
+        if (finished) return
+        finished = true
+        cleanup()
+        resolve([...identities.values()])
+      }
+      const finishError = (error: Error) => {
+        if (finished) return
+        finished = true
+        cleanup()
+        reject(new HttpError(502, `无法读取 Agent 身份:${error.message}`, 'profile_identity_failed'))
+      }
+      const send = (id: string, method: string, params: JsonObject) => {
+        socket.send(JSON.stringify({ jsonrpc: '2.0', id, method, params }))
+      }
+
+      socket.on('message', raw => {
+        let frame: JsonObject
+        try { frame = object(JSON.parse(String(raw))) } catch { return }
+        if (!requestedProfiles && object(frame.params).type === 'gateway.ready') {
+          requestedProfiles = true
+          send('profiles', 'profiles.list', { include_sessions: false })
+          return
+        }
+        if (String(frame.id ?? '') === 'profiles') {
+          const profiles = Array.isArray(object(frame.result).profiles)
+            ? object(frame.result).profiles as unknown[] : []
+          for (const entry of profiles) {
+            const outer = object(entry)
+            const profile = Object.keys(object(outer.profile)).length
+              ? object(outer.profile) : outer
+            const name = typeof profile.name === 'string' ? profile.name.trim() : ''
+            if (!name) continue
+            const meta = object(object(profile.ui_meta)['hermes-bots'])
+            const title = [meta.title, profile.display_name, profile.description, name]
+              .find(value => typeof value === 'string' && value.trim()) as string
+            const identity: RemoteProfileIdentity = {
+              name,
+              displayName: title.trim(),
+              model: typeof profile.model === 'string' ? profile.model : '',
+              ...(typeof meta.color === 'string' && /^#[0-9a-f]{6}$/i.test(meta.color)
+                ? { color: meta.color } : {}),
+            }
+            identities.set(name, identity)
+            if (profile.has_avatar === true) {
+              const id = `avatar-${avatarRequests.size}`
+              avatarRequests.set(id, name)
+              send(id, 'profiles.get_asset', { name, asset: 'avatar' })
+            }
+          }
+          if (avatarRequests.size === 0) finish()
+          return
+        }
+        const id = String(frame.id ?? '')
+        const name = avatarRequests.get(id)
+        if (!name) return
+        avatarRequests.delete(id)
+        const data = object(frame.result).data
+        if (typeof data === 'string'
+          && data.length <= 2_800_000
+          && /^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i.test(data)) {
+          const identity = identities.get(name)
+          if (identity) identity.avatar = data
+        }
+        if (avatarRequests.size === 0) finish()
+      })
+      socket.once('error', finishError)
+      socket.once('unexpected-response', () => finishError(new Error('Hermes rejected identity socket')))
+      socket.once('close', () => {
+        if (!finished) finishError(new Error('Hermes closed identity socket'))
+      })
+    })
+  }
+}

+ 77 - 2
src/server/routes.ts

@@ -30,6 +30,7 @@ import { receiveGroupUploads, uploadMarkdown, UploadStore } from './uploads.js'
 import { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
 import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
+import { UpstreamProfileIdentityService } from './profileIdentities.js'
 
 type JsonObject = Record<string, unknown>
 
@@ -44,6 +45,7 @@ export interface RouteDependencies {
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
+  profileIdentities: UpstreamProfileIdentityService
 }
 
 function body(ctx: Koa.Context): JsonObject {
@@ -437,16 +439,37 @@ async function addPairedChildNode(
   const profiles = await requestJSON(new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profiles`, nodeURL), {
     headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` },
   })
+  let profileIdentities = new Map<string, JsonObject>()
+  try {
+    const identityPayload = await requestJSON(
+      new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profile-identities`, nodeURL),
+      { headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` } },
+    )
+    profileIdentities = new Map(
+      (Array.isArray(identityPayload.profiles) ? identityPayload.profiles : [])
+        .flatMap(value => {
+          if (!value || typeof value !== 'object' || Array.isArray(value)) return []
+          const identity = value as JsonObject
+          return typeof identity.name === 'string' ? [[identity.name, identity] as const] : []
+        }),
+    )
+  } catch {
+    // Older 8800 nodes remain pairable; they simply use their REST names.
+  }
   const profileItems = normalizedProfiles(profiles).flatMap(entry => {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
     const value = entry as JsonObject
     const name = typeof value.name === 'string' ? value.name : ''
     if (!name) return []
+    const identity = profileIdentities.get(name)
     return [{
       name,
-      displayName: typeof value.agentName === 'string' ? value.agentName
+      displayName: typeof identity?.displayName === 'string' ? identity.displayName
+        : typeof value.agentName === 'string' ? value.agentName
         : typeof value.display_name === 'string' ? value.display_name : name,
       model: typeof value.model === 'string' ? value.model : '',
+      ...(typeof identity?.avatar === 'string' ? { avatar: identity.avatar } : {}),
+      ...(typeof identity?.color === 'string' ? { color: identity.color } : {}),
     }]
   })
   const registration = {
@@ -832,6 +855,23 @@ function sendLocalMedia(ctx: Koa.Context, path: string): void {
   ctx.body = createReadStream(path, { start, end: boundedEnd })
 }
 
+function pairedLocalMediaPath(config: ServerConfig, rawPath: string): string {
+  if (!rawPath || rawPath.length > 8_192 || rawPath.includes('\u0000')) {
+    throw new HttpError(400, '远程文件路径无效', 'invalid_node_file_path')
+  }
+  let file: string
+  try { file = realpathSync(rawPath) } catch {
+    throw new HttpError(404, '远程文件不存在', 'node_file_not_found')
+  }
+  const roots = [config.mediaRoot, config.attachmentsRoot, config.imagesRoot].flatMap(root => {
+    try { return [realpathSync(root)] } catch { return [] }
+  })
+  if (!roots.some(root => file.startsWith(`${root}${sep}`)) || !statSync(file).isFile()) {
+    throw new HttpError(403, '远程文件不在允许的目录中', 'node_file_forbidden')
+  }
+  return file
+}
+
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
 
@@ -877,6 +917,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       server_kind: 'yaoyao-web',
     })
   })
+  router.get('/api/profile-identities', async (ctx) => {
+    dependencies.auth.require(ctx, true)
+    json(ctx, 200, { profiles: await dependencies.profileIdentities.load() })
+  })
   router.get('/api/profiles', async (ctx) => {
     dependencies.auth.require(ctx, true)
     const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
@@ -1060,6 +1104,29 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { ok: true })
   })
 
+  router.get('/node/:deviceID/api/node-files', (ctx) => {
+    const token = bearerToken(ctx.get('authorization'))
+    dependencies.pairings.authorize(
+      ctx.params.deviceID,
+      token,
+      'history.read',
+    )
+    const value = typeof ctx.query.path === 'string' ? ctx.query.path : ''
+    sendLocalMedia(
+      ctx,
+      pairedLocalMediaPath(dependencies.config, value),
+    )
+  })
+  router.get('/node/:deviceID/api/profile-identities', async (ctx) => {
+    const token = bearerToken(ctx.get('authorization'))
+    dependencies.pairings.authorize(
+      ctx.params.deviceID,
+      token,
+      'agents.read',
+    )
+    json(ctx, 200, { profiles: await dependencies.profileIdentities.load() })
+  })
+
   router.all('/node/:deviceID/api/*nodePath', async (ctx) => {
     const rawPath = Array.isArray(ctx.params.nodePath)
       ? ctx.params.nodePath.join('/')
@@ -1109,7 +1176,8 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         ? undefined : optionalBody(ctx),
       rawBody,
       headers: requestHeaders,
-      clientAddress: ctx.req.socket.remoteAddress,
+      clientAddress: path === '/api/auth/ws-ticket'
+        ? undefined : ctx.req.socket.remoteAddress,
     })
     dependencies.pairings.updateCookies(ctx.params.deviceID, jar.header)
     sendUpstreamResponse(ctx, response, jar)
@@ -1509,6 +1577,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       dependencies,
     ))
   })
+  router.post('/api/app/groups/nodes/refresh-identities', async (ctx) => {
+    await proxy(ctx, dependencies.upstream, '/api/plugins/yaoyao/v1/nodes/refresh-identities', {
+      method: 'POST', requestBody: {},
+    })
+  })
   router.delete('/api/app/groups/nodes/:nodeID', async (ctx) => {
     const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
     await proxy(
@@ -1707,6 +1780,8 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       // phone's address here would make 9119 reject the ticket during Upgrade.
       clientAddress: upstreamPath === '/api/auth/ws-ticket'
         ? undefined : ctx.req.socket.remoteAddress,
+      maxResponseBytes: /\/plugins\/yaoyao\/v1\/nodes\/[0-9a-f-]{36}\/files$/.test(upstreamPath)
+        ? 256 * 1_024 * 1_024 : undefined,
     })
     sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
   })

+ 2 - 0
src/shared/types.ts

@@ -294,6 +294,8 @@ export interface GroupNodeProfile {
   name: string
   displayName: string
   model: string
+  avatar?: string
+  color?: string
 }
 
 export interface GroupNode {

+ 23 - 0
tests/client/remoteNodeFiles.test.ts

@@ -0,0 +1,23 @@
+import { describe, expect, it } from 'vitest'
+import { rewriteRemoteNodeFiles } from '@/components/workspace/viewModels'
+
+describe('remote node file links', () => {
+  it('routes remote images and files through the source node', () => {
+    const node = '11111111-1111-4111-8111-111111111111'
+    const content = [
+      '![图](</Users/remote/Agents/result one.png>)',
+      '[报告](/Users/remote/Agents/report.pdf)',
+      '[日志](file:///Users/remote/Agents/run.log)',
+    ].join('\n')
+    const rewritten = rewriteRemoteNodeFiles(content, node)
+    expect(rewritten).toContain(`/api/plugins/yaoyao/v1/nodes/${node}/files?path=%2FUsers%2Fremote%2FAgents%2Fresult%20one.png`)
+    expect(rewritten).toContain('path=%2FUsers%2Fremote%2FAgents%2Freport.pdf')
+    expect(rewritten).toContain('path=%2FUsers%2Fremote%2FAgents%2Frun.log')
+    expect(rewritten).not.toContain('](</Users/remote')
+  })
+
+  it('does not rewrite web URLs or local-agent content', () => {
+    expect(rewriteRemoteNodeFiles('[官网](https://example.com/a.png)', 'local'))
+      .toBe('[官网](https://example.com/a.png)')
+  })
+})