From ba82d8ec97e54dd489eb5b1356355f141feef567 Mon Sep 17 00:00:00 2001 From: Zhaowei Li <82752804+Legimity@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:12:58 +0800 Subject: [PATCH] fix(skills): expose shared expert skills in chat (#571) Co-authored-by: jubaoliang --- CHANGELOG.md | 1 + dashboard/src/pages/Chat/index.tsx | 9 +++++---- .../Experts/components/sharedExpert.test.ts | 20 +++++++++++++++++++ dashboard/src/utils/sharedExpert.ts | 16 +++++++++++++++ src/octop/api/routers/skills.py | 18 ++++++++++++----- tests/integration/test_agents_shared.py | 6 ++++++ 6 files changed, 61 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7725bf7f..ab0a3768 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 修复 +- 共享专家的技能列表现在会在聊天输入框中加载,非所有者可查看并选择专家已配置的技能 - 知识库文本文档编辑抽屉在「编辑」模式下点「保存」无响应(`name`/`format` 字段未挂载时 `validateFields` 缺值导致抛错被吞掉;#592) - 知识库文本文档编辑内容未改时点「保存」只关闭抽屉,不触发更新与重建索引 diff --git a/dashboard/src/pages/Chat/index.tsx b/dashboard/src/pages/Chat/index.tsx index 0d6a4afe..61c082aa 100644 --- a/dashboard/src/pages/Chat/index.tsx +++ b/dashboard/src/pages/Chat/index.tsx @@ -65,7 +65,10 @@ import { import { useLayoutMode } from "../../context/LayoutModeContext"; import { useBrowserSessionState } from "../../hooks/useBrowserSessionState"; import { prefetchVoiceConfig } from "../../hooks/useVoiceConfig"; -import { isSharedExpertViewer } from "../../utils/sharedExpert"; +import { + chatSkillCatalogAgentId, + isSharedExpertViewer, +} from "../../utils/sharedExpert"; import ChatDockPanels from "./components/ChatDockPanels"; import { ChatFilePreviewProvider } from "./ChatFilePreviewContext"; import { @@ -218,9 +221,7 @@ function ChatPageInner() { const { quickCards: expertQuickCards, welcomeSuffix } = useExpertChatWelcome(activeAgent); const { skills: chatSkills } = useSkills( - agentChatReady && !agentsLoading && !sharedExpertViewer - ? resolvedAgentId ?? null - : null, + chatSkillCatalogAgentId(resolvedAgentId, agentChatReady, agentsLoading), ); const [agentProfileOpen, setAgentProfileOpen] = useState(false); const [workspaceDrawerOpen, setWorkspaceDrawerOpen] = useState(false); diff --git a/dashboard/src/pages/Experts/components/sharedExpert.test.ts b/dashboard/src/pages/Experts/components/sharedExpert.test.ts index ff8ef425..f7140828 100644 --- a/dashboard/src/pages/Experts/components/sharedExpert.test.ts +++ b/dashboard/src/pages/Experts/components/sharedExpert.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + chatSkillCatalogAgentId, isOwnedExpert, isSharedExpertViewer, ownedExperts, @@ -22,6 +23,25 @@ describe("isSharedExpertViewer", () => { }); }); +describe("chatSkillCatalogAgentId", () => { + it("loads skills for a ready shared expert viewed by a non-owner", () => { + const shared = { + agent_id: "shared", + is_shared: true, + is_owner: false, + }; + expect(isSharedExpertViewer(shared)).toBe(true); + expect(chatSkillCatalogAgentId(shared.agent_id, true, false)).toBe( + "shared", + ); + }); + + it("waits until the expert is ready and agent loading has finished", () => { + expect(chatSkillCatalogAgentId("shared", false, false)).toBeNull(); + expect(chatSkillCatalogAgentId("shared", true, true)).toBeNull(); + }); +}); + describe("ownedExperts", () => { it("keeps owned experts and drops shared viewers for manage pages", () => { const agents = [ diff --git a/dashboard/src/utils/sharedExpert.ts b/dashboard/src/utils/sharedExpert.ts index 523f9cca..ac7fb04a 100644 --- a/dashboard/src/utils/sharedExpert.ts +++ b/dashboard/src/utils/sharedExpert.ts @@ -12,6 +12,22 @@ export function isOwnedExpert(agent: SharedExpertAccess): boolean { return !isSharedExpertViewer(agent); } +/** + * Resolve the expert whose skill catalog can be shown in chat. + * + * Shared-expert viewers have read access to the expert's skills; ownership is + * only required for mutations. Keep readiness/loading as the only UI gates so + * the composer can inspect and select skills exposed by a shared expert. + */ +export function chatSkillCatalogAgentId( + agentId: string | null | undefined, + agentChatReady: boolean, + agentsLoading: boolean, +): string | null { + if (!agentChatReady || agentsLoading) return null; + return agentId ?? null; +} + /** Experts the user owns — for Experts / Personalization / agent bars. */ export function ownedExperts(agents: T[]): T[] { return agents.filter(isOwnedExpert); diff --git a/src/octop/api/routers/skills.py b/src/octop/api/routers/skills.py index 50666d0d..455d6306 100644 --- a/src/octop/api/routers/skills.py +++ b/src/octop/api/routers/skills.py @@ -44,7 +44,7 @@ import yaml from fastapi import APIRouter, Depends, Request from pydantic import BaseModel -from octop.api.common.agent import require_agent_owner_row +from octop.api.common.agent import require_agent_owner_row, require_agent_row from octop.api.deps import current_user, get_server from octop.infra.agents.manager import ( skill_package_ids_list, @@ -89,10 +89,12 @@ async def _ctx( user: Any, as_user: int | None, server: Any, + owner_only: bool = True, ) -> _AgentCtx: assert server.app_runtime is not None registry = server.app_runtime.agent_registry - row = require_agent_owner_row(agent_id, user=user, as_user=as_user, server=server) + require = require_agent_owner_row if owner_only else require_agent_row + row = require(agent_id, user=user, as_user=as_user, server=server) cfg = registry.get_config(agent_id) agent = registry.get_agent(agent_id) return _AgentCtx(runtime=row, workspace=agent.workspace, config=cfg) @@ -415,7 +417,7 @@ async def _enabled_skill_names( user: Any, ) -> set[str]: """Return installed, non-disabled skill names for an agent.""" - await _ctx(agent_id, user=user, as_user=None, server=server) + await _ctx(agent_id, user=user, as_user=None, server=server, owner_only=False) assert server.app_runtime is not None names: set[str] = set() for summary in await server.app_runtime.agent_registry.list_skill_summaries(agent_id): @@ -450,7 +452,7 @@ async def list_skills( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> list[dict[str, Any]]: - await _ctx(agent_id, user=user, as_user=as_user, server=server) + await _ctx(agent_id, user=user, as_user=as_user, server=server, owner_only=False) assert server.app_runtime is not None return cast( list[dict[str, Any]], @@ -528,7 +530,13 @@ async def get_skill( user: Any = Depends(current_user), server: Any = Depends(get_server), ) -> dict[str, Any]: - ctx = await _ctx(agent_id, user=user, as_user=as_user, server=server) + ctx = await _ctx( + agent_id, + user=user, + as_user=as_user, + server=server, + owner_only=False, + ) resolved = await _resolve_skill(ctx.workspace, name) if resolved is None: raise OctopError(ErrorCode.NOT_FOUND, f"skill {name!r} not found") diff --git a/tests/integration/test_agents_shared.py b/tests/integration/test_agents_shared.py index 12ffc022..eb2bd1a5 100644 --- a/tests/integration/test_agents_shared.py +++ b/tests/integration/test_agents_shared.py @@ -108,6 +108,12 @@ async def test_peer_cannot_mutate_shared_agent_workspace_or_skills(env) -> None: ) assert response.status_code == 403 + response = await client.get( + f"/api/agents/{agent_id}/skills", + headers=peer_auth, + ) + assert response.status_code == 200, response.text + response = await client.post( "/api/mbti/apply", headers={**peer_auth, "X-Octop-Agent-Id": agent_id},