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

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

LSamien 2 месяцев назад
Родитель
Сommit
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`。
 `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 重载与验证步骤。
 需要由自动化 Agent 部署或升级时,请直接使用 [Agent 安装手册](docs/agent-install.md)。其中包含固定版本校验、备份、同步、单一 Dashboard 重载与验证步骤。
 
 
@@ -171,11 +171,11 @@ npm run dev
 
 
 ## 手机扫码与跨节点 Agent
 ## 手机扫码与跨节点 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` 区分。
 4. 扫描后,该节点的 Bots、普通聊天历史和 Agent Profile 会按节点身份加入手机;同名 Profile 使用 `nodeId + profile` 区分。
 
 
 二维码不包含账号密码、长期 Token 或 Dashboard Cookie。Web 服务会签发可撤销的设备 Token,并把代理所需的 Dashboard Cookie 加密保存在 `HERMES_YAOYAO_HOME`。iOS Token 按服务器账号保存在系统 Keychain。忘记 iOS 中的节点或在 Web 中撤销设备都会终止后续访问。
 二维码不包含账号密码、长期 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` |
 | Git 发布标签 | `v0.2.9` | `release.json` 的 `gitTag` 与 `git describe --tags --exact-match HEAD` |
 | 夭夭 Web | `0.2.9` | `release.json` 与 `package.json` 的 `version` |
 | 夭夭 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 "$(git describe --tags --exact-match HEAD)" = "$RELEASE_VERSION"
 test "$(node -p \"require('./package.json').version\")" = "0.2.9"
 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
 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/plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/group_plugin_api.py"
 test -f "$PLUGIN_DIR/dist/index.js"
 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` 的同步命令。
 `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 json
 import os
 import os
 from pathlib import Path
 from pathlib import Path
+import re
 import tempfile
 import tempfile
 import threading
 import threading
 import time
 import time
@@ -254,14 +255,30 @@ class PairedNodeRegistry:
             seen.add(name)
             seen.add(name)
             display_name = raw.get("displayName", "")
             display_name = raw.get("displayName", "")
             model = raw.get("model", "")
             model = raw.get("model", "")
+            avatar = raw.get("avatar", "")
+            color = raw.get("color", "")
             if not isinstance(display_name, str) or len(display_name) > 100:
             if not isinstance(display_name, str) or len(display_name) > 100:
                 raise ValueError("profiles.displayName is invalid")
                 raise ValueError("profiles.displayName is invalid")
             if not isinstance(model, str) or len(model) > 4096:
             if not isinstance(model, str) or len(model) > 4096:
                 raise ValueError("profiles.model is invalid")
                 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({
             result.append({
                 "name": name,
                 "name": name,
                 "displayName": display_name.strip(),
                 "displayName": display_name.strip(),
                 "model": model.strip(),
                 "model": model.strip(),
+                **({"avatar": avatar} if avatar else {}),
+                **({"color": color} if color else {}),
             })
             })
         return result
         return result
 
 

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

@@ -7,6 +7,7 @@ import base64
 import hashlib
 import hashlib
 import importlib
 import importlib
 import inspect
 import inspect
+import json
 import logging
 import logging
 import os
 import os
 import re
 import re
@@ -19,12 +20,15 @@ from importlib.machinery import ModuleSpec
 from pathlib import Path
 from pathlib import Path
 from types import ModuleType
 from types import ModuleType
 from typing import Annotated, Any
 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 uuid import UUID, uuid4
 
 
 from fastapi import APIRouter, File, Query, Request, UploadFile, WebSocket
 from fastapi import APIRouter, File, Query, Request, UploadFile, WebSocket
 from fastapi import Path as APIPath
 from fastapi import Path as APIPath
 from fastapi.exceptions import RequestValidationError
 from fastapi.exceptions import RequestValidationError
-from fastapi.responses import JSONResponse
+from fastapi.responses import JSONResponse, StreamingResponse
 from fastapi.routing import APIRoute
 from fastapi.routing import APIRoute
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -1406,6 +1410,52 @@ async def list_paired_nodes() -> dict[str, object]:
     return {"items": nodes}
     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]:
 async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
     removed = await _node_registry_call("revoke", str(node_id))
     removed = await _node_registry_call("revoke", str(node_id))
     if not removed:
     if not removed:
@@ -1413,6 +1463,69 @@ async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
     return {"ok": True}
     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(
 async def node_worker_submit_prompt(
     runtime_session_id: str,
     runtime_session_id: str,
     payload: NodeWorkerPromptRequest,
     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", register_paired_node, methods=["POST"])
     built.add_api_route("/nodes", list_paired_nodes, methods=["GET"])
     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(
     built.add_api_route(
         "/nodes/{node_id}", revoke_paired_node, methods=["DELETE"]
         "/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(
     built.add_api_route(
         "/node-worker/sessions", node_worker_open_session, methods=["POST"]
         "/node-worker/sessions", node_worker_open_session, methods=["POST"]
     )
     )

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

@@ -3,7 +3,7 @@
   "label": "夭夭",
   "label": "夭夭",
   "description": "文件归档、Agent 名称、语音设置与多设备群聊",
   "description": "文件归档、Agent 名称、语音设置与多设备群聊",
   "icon": "FolderOpen",
   "icon": "FolderOpen",
-  "version": "1.7.2",
+  "version": "1.7.3",
   "tab": { "path": "/yaoyao-files", "position": "after:files" },
   "tab": { "path": "/yaoyao-files", "position": "after:files" },
   "entry": "dist/index.js",
   "entry": "dist/index.js",
   "css": "dist/style.v1.4.0.css",
   "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",
                 "serverUrl": "https://remote.example/node/11111111-1111-4111-8111-111111111111",
                 "fingerprint": "fingerprint",
                 "fingerprint": "fingerprint",
                 "accessToken": "top-secret-token",
                 "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.assertNotIn("accessToken", registered)
+            self.assertEqual(
+                registered["profiles"][0]["avatar"],
+                "data:image/png;base64,AAAA",
+            )
             persisted = (Path(directory) / "paired-group-nodes.json").read_text()
             persisted = (Path(directory) / "paired-group-nodes.json").read_text()
             self.assertNotIn("top-secret-token", persisted)
             self.assertNotIn("top-secret-token", persisted)
             self.assertEqual(registry.get(node_id)["accessToken"], "top-secret-token")
             self.assertEqual(registry.get(node_id)["accessToken"], "top-secret-token")

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

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

+ 1 - 1
release.json

@@ -2,6 +2,6 @@
   "schemaVersion": 1,
   "schemaVersion": 1,
   "releaseVersion": "0.2.9",
   "releaseVersion": "0.2.9",
   "webVersion": "0.2.9",
   "webVersion": "0.2.9",
-  "pluginVersion": "1.7.2",
+  "pluginVersion": "1.7.3",
   "gitTag": "v0.2.9"
   "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 => {
       profiles: values(node.profiles).flatMap(profileValue => {
         const profile = record(profileValue)
         const profile = record(profileValue)
         const name = String(profile.name ?? '')
         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> {
 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 payload = unwrapData(await apiRequest<unknown>(apiUrl(`${BASE}/rooms`, { cursor, limit: Math.max(1, Math.min(100, limit)), archived })))
   const source = record(payload)
   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'
   const sender = message.senderKind === 'agent'
     ? agents.find(agent => agent.id === message.senderId)
     ? agents.find(agent => agent.id === message.senderId)
     : undefined
     : undefined
+  const content = sender && sender.nodeId !== 'local'
+    ? rewriteRemoteNodeFiles(message.content, sender.nodeId)
+    : message.content
   return {
   return {
     id: message.id,
     id: message.id,
     role: message.senderKind === 'human' ? 'user' : message.senderKind === 'agent' ? 'assistant' : 'system',
     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.
     // Only local agents share this Web client's Desktop Bots identity map.
     // Remote nodes keep their own identity and intentionally do not collide by
     // Remote nodes keep their own identity and intentionally do not collide by
     // profile slug with a local agent.
     // 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,
     reasoning: message.reasoning,
     createdAt: message.createdAt < 10_000_000_000 ? message.createdAt * 1000 : message.createdAt,
     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',
     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 {
 export function groupInteraction(interaction?: GroupInteraction): UiInteraction | null {
   if (!interaction || interaction.status !== 'pending') return null
   if (!interaction || interaction.status !== 'pending') return null
   const payload = record(interaction.payload)
   const payload = record(interaction.payload)

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

@@ -7,7 +7,7 @@ import type {
 import {
 import {
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   addGroupAgent, approveGroupInteraction as approveApi, archiveGroupRoom as archiveApi, archiveGroupTopic as archiveTopicApi,
   clarifyGroupInteraction as clarifyApi, createGroupRoom as createApi, getGroupCapabilities,
   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,
   removeGroupAgent as removeAgentApi, restoreGroupRoom as restoreRoomApi, restoreGroupTopic as restoreTopicApi, sendGroupMessage as sendApi, updateGroupAgent as updateAgentApi,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
   updateGroupRoom as updateRoomApi, updateGroupTopic as updateTopicApi, uploadGroupFiles, type AgentSeed,
 } from '@/api/groups'
 } from '@/api/groups'
@@ -145,6 +145,9 @@ export const useGroupsStore = defineStore('groups', () => {
       nodes.value = []
       nodes.value = []
       return
       return
     }
     }
+    if (auth.user?.role === 'admin') {
+      try { await refreshGroupNodeIdentities() } catch { /* Keep last known node roster. */ }
+    }
     nodes.value = await getGroupNodes()
     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'}`)
           throw new Error(`团队协议版本不兼容:支持 ${SUPPORTED_GROUP_PROTOCOL_VERSION_LABEL},服务器返回 v${anchor.protocolVersion || 'unknown'}`)
         }
         }
         const loadedRooms = await loadRooms()
         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
         if (expectedGeneration !== generation) return
         await loadPinnedTopics()
         await loadPinnedTopics()
         if (expectedGeneration !== generation) return
         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 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 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 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 {
 function serverAddress(value: string): string {
   try { return new URL(value).host }
   try { return new URL(value).host }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
   catch { return value.replace(/^https?:\/\//i, '').replace(/\/.*$/, '') }
@@ -188,6 +194,7 @@ const profiles = computed<GroupProfileOption[]>(() => [
     displayName: profile.displayName || profile.name,
     displayName: profile.displayName || profile.name,
     nodeId: node.nodeId,
     nodeId: node.nodeId,
     nodeLabel: node.name,
     nodeLabel: node.name,
+    avatar: profile.avatar,
   }))),
   }))),
 ])
 ])
 const availableProfiles = computed(() => profiles.value.filter(profile => !groups.agents.some(agent =>
 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>
         <template #header-actions>
           <div class="group-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>
             <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>
             <button class="icon-button" type="button" title="管理团队" aria-label="管理团队" :disabled="!groups.selectedRoom" @click="openSelectedRoomManager"><AppIcon name="dots" /></button>
           </div>
           </div>
         </template>
         </template>

+ 7 - 0
src/server/app.ts

@@ -12,6 +12,7 @@ import { RealtimeLeaseStore } from './leases.js'
 import { NodePairingStore } from './pairing.js'
 import { NodePairingStore } from './pairing.js'
 import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
 import { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
+import { UpstreamProfileIdentityService } from './profileIdentities.js'
 import { createApiRouter } from './routes.js'
 import { createApiRouter } from './routes.js'
 import {
 import {
   applySecurityHeaders,
   applySecurityHeaders,
@@ -35,6 +36,7 @@ export interface ApplicationOptions {
   auth?: LocalAuthStore
   auth?: LocalAuthStore
   upstreamSession?: UpstreamServiceSession
   upstreamSession?: UpstreamServiceSession
   accountPairings?: AccountLoginPairingStore
   accountPairings?: AccountLoginPairingStore
+  profileIdentities?: UpstreamProfileIdentityService
 }
 }
 
 
 export interface ApplicationRuntime {
 export interface ApplicationRuntime {
@@ -49,6 +51,7 @@ export interface ApplicationRuntime {
   auth: LocalAuthStore
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
   accountPairings: AccountLoginPairingStore
+  profileIdentities: UpstreamProfileIdentityService
   close(): void
   close(): void
 }
 }
 
 
@@ -101,6 +104,8 @@ export function createApplication(options: ApplicationOptions = {}): Application
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
     () => auth.upstreamCredentials(configuredUpstreamCredentials),
   )
   )
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
   const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
+  const profileIdentities = options.profileIdentities
+    ?? new UpstreamProfileIdentityService(config, upstreamSession)
   try {
   try {
     uploads.cleanupUncommitted()
     uploads.cleanupUncommitted()
   } catch {
   } catch {
@@ -213,6 +218,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     auth,
     auth,
     upstreamSession,
     upstreamSession,
     accountPairings,
     accountPairings,
+    profileIdentities,
   })
   })
   app.use(router.routes())
   app.use(router.routes())
   app.use(router.allowedMethods({ throw: false }))
   app.use(router.allowedMethods({ throw: false }))
@@ -238,6 +244,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     auth,
     auth,
     upstreamSession,
     upstreamSession,
     accountPairings,
     accountPairings,
+    profileIdentities,
     close: () => uploads.close(),
     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 { SystemUpdateManager, type SystemUpdateStatus } from './updateManager.js'
 import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
 import { LocalAuthStore, type LocalUser, UpstreamServiceSession } from './localAuth.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
 import { AccountLoginPairingStore } from './accountPairing.js'
+import { UpstreamProfileIdentityService } from './profileIdentities.js'
 
 
 type JsonObject = Record<string, unknown>
 type JsonObject = Record<string, unknown>
 
 
@@ -44,6 +45,7 @@ export interface RouteDependencies {
   auth: LocalAuthStore
   auth: LocalAuthStore
   upstreamSession: UpstreamServiceSession
   upstreamSession: UpstreamServiceSession
   accountPairings: AccountLoginPairingStore
   accountPairings: AccountLoginPairingStore
+  profileIdentities: UpstreamProfileIdentityService
 }
 }
 
 
 function body(ctx: Koa.Context): JsonObject {
 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), {
   const profiles = await requestJSON(new URL(`${nodeURL.pathname.replace(/\/$/, '')}/api/profiles`, nodeURL), {
     headers: { accept: 'application/json', authorization: `Bearer ${claimed.token}` },
     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 => {
   const profileItems = normalizedProfiles(profiles).flatMap(entry => {
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
     if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return []
     const value = entry as JsonObject
     const value = entry as JsonObject
     const name = typeof value.name === 'string' ? value.name : ''
     const name = typeof value.name === 'string' ? value.name : ''
     if (!name) return []
     if (!name) return []
+    const identity = profileIdentities.get(name)
     return [{
     return [{
       name,
       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,
         : typeof value.display_name === 'string' ? value.display_name : name,
       model: typeof value.model === 'string' ? value.model : '',
       model: typeof value.model === 'string' ? value.model : '',
+      ...(typeof identity?.avatar === 'string' ? { avatar: identity.avatar } : {}),
+      ...(typeof identity?.color === 'string' ? { color: identity.color } : {}),
     }]
     }]
   })
   })
   const registration = {
   const registration = {
@@ -832,6 +855,23 @@ function sendLocalMedia(ctx: Koa.Context, path: string): void {
   ctx.body = createReadStream(path, { start, end: boundedEnd })
   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 {
 export function createApiRouter(dependencies: RouteDependencies): Router {
   const router = new Router()
   const router = new Router()
 
 
@@ -877,6 +917,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       server_kind: 'yaoyao-web',
       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) => {
   router.get('/api/profiles', async (ctx) => {
     dependencies.auth.require(ctx, true)
     dependencies.auth.require(ctx, true)
     const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
     const profilesResponse = await dependencies.upstreamSession.request('/api/profiles')
@@ -1060,6 +1104,29 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     json(ctx, 200, { ok: true })
     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) => {
   router.all('/node/:deviceID/api/*nodePath', async (ctx) => {
     const rawPath = Array.isArray(ctx.params.nodePath)
     const rawPath = Array.isArray(ctx.params.nodePath)
       ? ctx.params.nodePath.join('/')
       ? ctx.params.nodePath.join('/')
@@ -1109,7 +1176,8 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
         ? undefined : optionalBody(ctx),
         ? undefined : optionalBody(ctx),
       rawBody,
       rawBody,
       headers: requestHeaders,
       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)
     dependencies.pairings.updateCookies(ctx.params.deviceID, jar.header)
     sendUpstreamResponse(ctx, response, jar)
     sendUpstreamResponse(ctx, response, jar)
@@ -1509,6 +1577,11 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       dependencies,
       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) => {
   router.delete('/api/app/groups/nodes/:nodeID', async (ctx) => {
     const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
     const nodeID = canonicalUUID(ctx.params.nodeID, 'node ID')
     await proxy(
     await proxy(
@@ -1707,6 +1780,8 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       // phone's address here would make 9119 reject the ticket during Upgrade.
       // phone's address here would make 9119 reject the ticket during Upgrade.
       clientAddress: upstreamPath === '/api/auth/ws-ticket'
       clientAddress: upstreamPath === '/api/auth/ws-ticket'
         ? undefined : ctx.req.socket.remoteAddress,
         ? 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)
     sendUpstreamResponse(ctx, response, dependencies.upstreamSession.jar)
   })
   })

+ 2 - 0
src/shared/types.ts

@@ -294,6 +294,8 @@ export interface GroupNodeProfile {
   name: string
   name: string
   displayName: string
   displayName: string
   model: string
   model: string
+  avatar?: string
+  color?: string
 }
 }
 
 
 export interface GroupNode {
 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)')
+  })
+})