fix(skills): expose shared expert skills in chat (#571)

Co-authored-by: jubaoliang <jubaoliang@gmail.com>
This commit is contained in:
Zhaowei Li
2026-09-07 19:12:58 +08:00
committed by GitHub
co-authored by jubaoliang
parent 2d9c2c8e66
commit ba82d8ec97
6 changed files with 61 additions and 9 deletions
+1
View File
@@ -8,6 +8,7 @@
### 修复
- 共享专家的技能列表现在会在聊天输入框中加载,非所有者可查看并选择专家已配置的技能
- 知识库文本文档编辑抽屉在「编辑」模式下点「保存」无响应(`name`/`format` 字段未挂载时 `validateFields` 缺值导致抛错被吞掉;#592)
- 知识库文本文档编辑内容未改时点「保存」只关闭抽屉,不触发更新与重建索引
+5 -4
View File
@@ -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);
@@ -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 = [
+16
View File
@@ -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<T extends SharedExpertAccess>(agents: T[]): T[] {
return agents.filter(isOwnedExpert);
+13 -5
View File
@@ -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")
+6
View File
@@ -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},