mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
fix: fix delete skill ref
This commit is contained in:
parent
eb5d1da0e8
commit
577012b66d
@ -1247,6 +1247,13 @@ class SkillManagementService:
|
||||
"skill is referenced and requires name confirmation",
|
||||
status_code=409,
|
||||
)
|
||||
self._remove_skill_reference_consumers(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
skill=skill,
|
||||
user_id=skill.updated_by,
|
||||
updated_at=naive_utc_now(),
|
||||
)
|
||||
session.query(AgentSkillBinding).filter(
|
||||
AgentSkillBinding.tenant_id == tenant_id,
|
||||
AgentSkillBinding.skill_id == skill.id,
|
||||
@ -1998,6 +2005,121 @@ class SkillManagementService:
|
||||
binding.updated_by = user_id
|
||||
binding.updated_at = updated_at
|
||||
|
||||
def _remove_skill_reference_consumers(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
skill: Skill,
|
||||
user_id: str,
|
||||
updated_at,
|
||||
) -> None:
|
||||
agents = list(
|
||||
session.scalars(
|
||||
select(Agent)
|
||||
.join(AgentSkillBinding, AgentSkillBinding.agent_id == Agent.id)
|
||||
.where(
|
||||
AgentSkillBinding.tenant_id == tenant_id,
|
||||
AgentSkillBinding.skill_id == skill.id,
|
||||
Agent.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
if not agents:
|
||||
return
|
||||
|
||||
workflow_bindings_by_agent_id = self._workflow_inline_bindings_by_agent_id(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents],
|
||||
)
|
||||
for agent in agents:
|
||||
self._remove_agent_config_skill_ref(
|
||||
session,
|
||||
agent=agent,
|
||||
skill_name=skill.name,
|
||||
user_id=user_id,
|
||||
updated_at=updated_at,
|
||||
workflow_bindings=workflow_bindings_by_agent_id.get(agent.id, []),
|
||||
)
|
||||
agent.updated_by = user_id
|
||||
agent.updated_at = updated_at
|
||||
|
||||
def _remove_agent_config_skill_ref(
|
||||
self,
|
||||
session,
|
||||
*,
|
||||
agent: Agent,
|
||||
skill_name: str,
|
||||
user_id: str,
|
||||
updated_at,
|
||||
workflow_bindings: list[WorkflowAgentNodeBinding],
|
||||
) -> None:
|
||||
new_snapshot_id: str | None = None
|
||||
previous_active_snapshot_id = agent.active_config_snapshot_id
|
||||
if previous_active_snapshot_id:
|
||||
active_snapshot = session.scalar(
|
||||
select(AgentConfigSnapshot).where(
|
||||
AgentConfigSnapshot.tenant_id == agent.tenant_id,
|
||||
AgentConfigSnapshot.agent_id == agent.id,
|
||||
AgentConfigSnapshot.id == previous_active_snapshot_id,
|
||||
)
|
||||
)
|
||||
if active_snapshot is not None:
|
||||
agent_soul = AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict)
|
||||
agent_soul.config_skills = self._remove_config_skill_ref(agent_soul.config_skills, skill_name)
|
||||
new_snapshot = AgentConfigSnapshot(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
version=self._next_agent_config_version(session, tenant_id=agent.tenant_id, agent_id=agent.id),
|
||||
config_snapshot=agent_soul,
|
||||
version_note=f"Removed workspace skill {skill_name}",
|
||||
created_by=user_id,
|
||||
)
|
||||
session.add(new_snapshot)
|
||||
session.flush()
|
||||
session.add(
|
||||
AgentConfigRevision(
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
previous_snapshot_id=active_snapshot.id,
|
||||
current_snapshot_id=new_snapshot.id,
|
||||
revision=self._next_agent_config_revision(
|
||||
session,
|
||||
tenant_id=agent.tenant_id,
|
||||
agent_id=agent.id,
|
||||
),
|
||||
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
|
||||
version_note=f"Removed workspace skill {skill_name}",
|
||||
created_by=user_id,
|
||||
)
|
||||
)
|
||||
agent.active_config_snapshot_id = new_snapshot.id
|
||||
new_snapshot_id = new_snapshot.id
|
||||
|
||||
drafts = list(
|
||||
session.scalars(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.tenant_id == agent.tenant_id,
|
||||
AgentConfigDraft.agent_id == agent.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
for draft in drafts:
|
||||
draft_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
|
||||
draft_soul.config_skills = self._remove_config_skill_ref(draft_soul.config_skills, skill_name)
|
||||
draft.config_snapshot = draft_soul
|
||||
if new_snapshot_id and draft.base_snapshot_id == previous_active_snapshot_id:
|
||||
draft.base_snapshot_id = new_snapshot_id
|
||||
draft.updated_by = user_id
|
||||
draft.updated_at = updated_at
|
||||
|
||||
for binding in workflow_bindings:
|
||||
if new_snapshot_id:
|
||||
binding.current_snapshot_id = new_snapshot_id
|
||||
binding.updated_by = user_id
|
||||
binding.updated_at = updated_at
|
||||
|
||||
@staticmethod
|
||||
def _upsert_config_skill_ref(
|
||||
current: list[AgentConfigSkillRefConfig],
|
||||
@ -2010,6 +2132,13 @@ class SkillManagementService:
|
||||
by_name[skill_ref.name] = skill_ref
|
||||
return [by_name[name] for name in order if name in by_name]
|
||||
|
||||
@staticmethod
|
||||
def _remove_config_skill_ref(
|
||||
current: list[AgentConfigSkillRefConfig],
|
||||
skill_name: str,
|
||||
) -> list[AgentConfigSkillRefConfig]:
|
||||
return [item for item in current if item.name != skill_name]
|
||||
|
||||
@staticmethod
|
||||
def _next_agent_config_version(session, *, tenant_id: str, agent_id: str) -> int:
|
||||
return (
|
||||
|
||||
@ -10,7 +10,7 @@ from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import delete, func, select
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
from models.account import Account
|
||||
@ -1255,6 +1255,28 @@ def test_duplicate_skill_copies_latest_published_content_without_history() -> No
|
||||
assert service.list_versions(tenant_id=TENANT, skill_id=duplicated["id"]) == {"data": []}
|
||||
|
||||
|
||||
def test_duplicate_skill_does_not_copy_agent_references() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
payload=SkillCreatePayload(name="finance-sop"),
|
||||
)
|
||||
service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]])
|
||||
|
||||
duplicated = service.duplicate_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"])
|
||||
|
||||
assert duplicated["reference_count"] == 0
|
||||
assert service.list_skill_references(tenant_id=TENANT, skill_id=duplicated["id"]) == {"data": []}
|
||||
assert service.list_agent_bindings(tenant_id=TENANT, agent_id=AGENT)["skill_ids"] == [created["id"]]
|
||||
listed = service.list_skills(tenant_id=TENANT, keyword=None, tags=[], page=1, limit=10)
|
||||
ref_counts_by_name = {skill["name"]: skill["reference_count"] for skill in listed["data"]}
|
||||
assert ref_counts_by_name == {
|
||||
"finance-sop": 1,
|
||||
"finance-sop-copy": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_duplicate_unpublished_skill_copies_current_draft() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop"))
|
||||
@ -1286,6 +1308,94 @@ def test_delete_skill_requires_confirmation_when_referenced() -> None:
|
||||
assert service.list_skills(tenant_id=TENANT)["data"] == []
|
||||
|
||||
|
||||
def test_delete_skill_removes_synced_agent_config_skill_refs() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop"))
|
||||
with session_factory.create_session() as session:
|
||||
agent_snapshot = AgentConfigSnapshot(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
version=1,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
config_skills=[
|
||||
AgentConfigSkillRefConfig(
|
||||
name="finance-sop",
|
||||
description="Finance SOP",
|
||||
file_id="workspace-skill-file",
|
||||
size=1,
|
||||
hash="workspace-skill-hash",
|
||||
),
|
||||
AgentConfigSkillRefConfig(
|
||||
name="inline-helper",
|
||||
description="Inline helper",
|
||||
file_id="inline-skill-file",
|
||||
size=1,
|
||||
hash="inline-skill-hash",
|
||||
),
|
||||
]
|
||||
),
|
||||
created_by=USER,
|
||||
)
|
||||
session.add(agent_snapshot)
|
||||
session.flush()
|
||||
agent = session.get(Agent, AGENT)
|
||||
assert agent is not None
|
||||
agent.active_config_snapshot_id = agent_snapshot.id
|
||||
session.add(
|
||||
AgentConfigDraft(
|
||||
tenant_id=TENANT,
|
||||
agent_id=AGENT,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id=agent_snapshot.id,
|
||||
config_snapshot=AgentSoulConfig(
|
||||
config_skills=[
|
||||
AgentConfigSkillRefConfig(
|
||||
name="finance-sop",
|
||||
description="Finance SOP",
|
||||
file_id="workspace-skill-file",
|
||||
size=1,
|
||||
hash="workspace-skill-hash",
|
||||
)
|
||||
]
|
||||
),
|
||||
created_by=USER,
|
||||
updated_by=USER,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]])
|
||||
|
||||
deleted = service.delete_skill(tenant_id=TENANT, skill_id=created["id"], confirmation_name="finance-sop")
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
agent = session.get(Agent, AGENT)
|
||||
assert agent is not None
|
||||
active_snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id)
|
||||
draft = session.scalar(
|
||||
select(AgentConfigDraft).where(
|
||||
AgentConfigDraft.agent_id == AGENT,
|
||||
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
|
||||
)
|
||||
)
|
||||
binding_count = session.scalar(select(func.count()).select_from(AgentSkillBinding))
|
||||
|
||||
assert deleted == {"id": created["id"], "deleted": True}
|
||||
assert active_snapshot is not None
|
||||
assert draft is not None
|
||||
assert binding_count == 0
|
||||
assert active_snapshot.version == 2
|
||||
active_skill_names = [
|
||||
item.name for item in AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict).config_skills
|
||||
]
|
||||
draft_skill_names = [item.name for item in AgentSoulConfig.model_validate(draft.config_snapshot_dict).config_skills]
|
||||
assert active_skill_names == ["inline-helper"]
|
||||
assert draft_skill_names == []
|
||||
assert draft.base_snapshot_id == active_snapshot.id
|
||||
|
||||
|
||||
def test_import_skill_package_creates_draft_and_rejects_name_conflicts() -> None:
|
||||
package = io.BytesIO()
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
|
||||
@ -8,15 +8,15 @@ import type {
|
||||
} from './add-actions-context'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { AgentOrchestrateAddActionsContext } from './add-actions-context'
|
||||
import { useAgentOrchestrateReadOnly } from './read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from './read-only-context'
|
||||
|
||||
export function AgentOrchestrateAddActionsProvider({ children }: { children: ReactNode }) {
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const [actions, setActions] = useState<AgentOrchestrateAddActions>({})
|
||||
|
||||
const registerAction = useCallback(
|
||||
(key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => {
|
||||
if (readOnly) return () => undefined
|
||||
if (isViewingVersion) return () => undefined
|
||||
|
||||
setActions((currentActions) => {
|
||||
if (currentActions[key] === action) return currentActions
|
||||
@ -37,15 +37,15 @@ export function AgentOrchestrateAddActionsProvider({ children }: { children: Rea
|
||||
})
|
||||
}
|
||||
},
|
||||
[readOnly],
|
||||
[isViewingVersion],
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
actions: readOnly ? {} : actions,
|
||||
actions: isViewingVersion ? {} : actions,
|
||||
registerAction,
|
||||
}),
|
||||
[actions, readOnly, registerAction],
|
||||
[actions, isViewingVersion, registerAction],
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@ -4,7 +4,7 @@ import type { ButtonProps } from '@langgenius/dify-ui/button'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
|
||||
|
||||
type ConfigureSectionAddButtonProps = Omit<
|
||||
ButtonProps,
|
||||
@ -19,9 +19,9 @@ export function ConfigureSectionAddButton({
|
||||
...props
|
||||
}: ConfigureSectionAddButtonProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
|
||||
if (readOnly) return null
|
||||
if (isViewingVersion) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
|
||||
@ -12,7 +12,10 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentConfigApiContextProvider } from '../../config-context'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentFiles } from '../index'
|
||||
|
||||
type ConfigFileQueryOptionsInput = {
|
||||
@ -161,11 +164,13 @@ function renderAgentFiles({
|
||||
initialOriginalConfig,
|
||||
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
apiContext?: AgentConfigApiContext
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -181,10 +186,12 @@ function renderAgentFiles({
|
||||
initialDraft={initialDraft}
|
||||
initialOriginalConfig={initialOriginalConfig}
|
||||
>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentFiles />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientProvider>,
|
||||
@ -632,8 +639,8 @@ describe('AgentFiles', () => {
|
||||
expect(snapshot.config_note).toBe('')
|
||||
})
|
||||
|
||||
it('should keep flat config files visible without drive-prefix filtering and disable add in read-only mode', () => {
|
||||
renderAgentFiles({ readOnly: true })
|
||||
it('should keep flat config files visible without drive-prefix filtering and disable add when viewing a version', () => {
|
||||
renderAgentFiles({ readOnly: true, viewingVersion: true })
|
||||
|
||||
expect(screen.getByText('diagram.png')).toBeInTheDocument()
|
||||
expect(screen.getByText('brief.md')).toBeInTheDocument()
|
||||
@ -641,4 +648,12 @@ describe('AgentFiles', () => {
|
||||
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderAgentFiles({ readOnly: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -23,7 +23,10 @@ import { AgentKnowledgeRetrieval } from './knowledge'
|
||||
import { AgentModelField } from './model-config/field'
|
||||
import { AgentPromptEditor } from './prompt-editor'
|
||||
import { AgentConfigurePublishBar } from './publish-bar'
|
||||
import { AgentOrchestrateReadOnlyContext } from './read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from './read-only-context'
|
||||
import { AgentSkills } from './skills'
|
||||
import { AgentTools } from './tools'
|
||||
|
||||
@ -144,41 +147,43 @@ export function AgentOrchestratePanel({
|
||||
/>
|
||||
)}
|
||||
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
label={showHeader ? undefined : orchestrateLabel}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
|
||||
scrollbar: hasBottomAction ? 'z-20' : undefined,
|
||||
}}
|
||||
>
|
||||
<AgentConfigApiContextProvider value={configApiContext}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentBuildDraftChangedKeysProvider
|
||||
changedKeys={
|
||||
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
|
||||
}
|
||||
>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
</AgentBuildDraftChangedKeysProvider>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={!!selectedVersionSnapshot}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 overflow-hidden"
|
||||
label={showHeader ? undefined : orchestrateLabel}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
|
||||
scrollbar: hasBottomAction ? 'z-20' : undefined,
|
||||
}}
|
||||
>
|
||||
<AgentConfigApiContextProvider value={configApiContext}>
|
||||
<AgentOrchestrateAddActionsProvider>
|
||||
<AgentBuildDraftChangedKeysProvider
|
||||
changedKeys={
|
||||
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
|
||||
}
|
||||
>
|
||||
<AgentModelField
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
onSelect={onSelectModel}
|
||||
/>
|
||||
<AgentPromptEditor />
|
||||
<AgentSkills />
|
||||
<AgentFiles />
|
||||
<AgentTools />
|
||||
<AgentKnowledgeRetrieval />
|
||||
<AgentAdvancedSettings />
|
||||
</AgentBuildDraftChangedKeysProvider>
|
||||
</AgentOrchestrateAddActionsProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
|
||||
{orchestrateBottomAction ? (
|
||||
<AgentOrchestrateBottomActions shrinkOnOpen={!bottomAction}>
|
||||
|
||||
@ -11,7 +11,10 @@ import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provid
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { RerankingModeEnum } from '@/models/datasets'
|
||||
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentKnowledgeRetrieval } from '../index'
|
||||
|
||||
vi.mock('@/context/workspace-state', async () => {
|
||||
@ -108,10 +111,12 @@ function ConfigSnapshotPreview() {
|
||||
function renderKnowledgeRetrieval({
|
||||
initialDraft = agentKnowledgeDraft,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
showConfigSnapshot = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
showConfigSnapshot?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient()
|
||||
@ -119,9 +124,11 @@ function renderKnowledgeRetrieval({
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentKnowledgeRetrieval />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentKnowledgeRetrieval />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
{showConfigSnapshot && <ConfigSnapshotPreview />}
|
||||
</AgentComposerProvider>
|
||||
</QueryClientProvider>,
|
||||
@ -159,8 +166,8 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide add, edit, and remove actions when readonly', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true })
|
||||
it('should hide add, edit, and remove actions when viewing a version', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true, viewingVersion: true })
|
||||
|
||||
expect(
|
||||
screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'),
|
||||
@ -181,6 +188,16 @@ describe('AgentKnowledgeRetrieval', () => {
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderKnowledgeRetrieval({ readOnly: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('User Interactions', () => {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { createContext, use } from 'react'
|
||||
|
||||
export const AgentOrchestrateReadOnlyContext = createContext(false)
|
||||
export const AgentOrchestrateViewingVersionContext = createContext(false)
|
||||
|
||||
export function useAgentOrchestrateReadOnly() {
|
||||
return use(AgentOrchestrateReadOnlyContext)
|
||||
}
|
||||
|
||||
export function useAgentOrchestrateViewingVersion() {
|
||||
return use(AgentOrchestrateViewingVersionContext)
|
||||
}
|
||||
|
||||
@ -12,7 +12,10 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentConfigApiContextProvider } from '../../config-context'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentSkills } from '../index'
|
||||
|
||||
type ConfigSkillInspectQueryOptionsInput = {
|
||||
@ -243,10 +246,12 @@ function renderAgentSkills({
|
||||
},
|
||||
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
|
||||
readOnly = false,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
apiContext?: AgentConfigApiContext
|
||||
readOnly?: boolean
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -259,10 +264,12 @@ function renderAgentSkills({
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigApiContextProvider value={apiContext}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value={readOnly}>
|
||||
<AgentSkills />
|
||||
<ConfigSnapshotProbe />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</AgentConfigApiContextProvider>
|
||||
</QueryClientProvider>,
|
||||
@ -553,7 +560,7 @@ describe('AgentSkills', () => {
|
||||
})
|
||||
})
|
||||
|
||||
const snapshot = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '{}')
|
||||
const snapshot = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
|
||||
expect(snapshot.config_skills).toEqual([])
|
||||
})
|
||||
|
||||
@ -1183,12 +1190,52 @@ describe('AgentSkills', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should disable add and remove actions when the section is read only', () => {
|
||||
const { container } = renderAgentSkills({ readOnly: true })
|
||||
it('should disable add and remove actions when viewing a version', () => {
|
||||
const { container } = renderAgentSkills({
|
||||
apiContext: {
|
||||
agentId: 'agent-1',
|
||||
draftType: 'draft',
|
||||
versionId: 'version-1',
|
||||
},
|
||||
readOnly: true,
|
||||
viewingVersion: true,
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(container.querySelector('[data-agent-skill-remove-button]')).toBeNull()
|
||||
})
|
||||
|
||||
it('should keep the add menu available for build draft skills', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderAgentSkills({
|
||||
apiContext: {
|
||||
agentId: 'agent-1',
|
||||
draftType: 'debug_build',
|
||||
},
|
||||
initialDraft: {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
skills: [],
|
||||
},
|
||||
readOnly: true,
|
||||
})
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.add/i,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.upload\.label/i,
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -42,7 +42,10 @@ import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentConfigApiContext } from '../config-context'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import {
|
||||
useAgentOrchestrateReadOnly,
|
||||
useAgentOrchestrateViewingVersion,
|
||||
} from '../read-only-context'
|
||||
import { AgentSkillItem } from './item'
|
||||
import { AgentSkillUploadDialog } from './upload-dialog'
|
||||
|
||||
@ -376,7 +379,7 @@ export function AgentSkills() {
|
||||
const skillsTip = t(($) => $['agentDetail.configure.skills.tip'])
|
||||
const skillsListId = 'agent-configure-skills-list'
|
||||
const queryClient = useQueryClient()
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false)
|
||||
const [addMenuView, setAddMenuView] = useState<'menu' | 'workspace-selector'>('menu')
|
||||
const [isUploadOpen, setIsUploadOpen] = useState(false)
|
||||
@ -560,7 +563,7 @@ export function AgentSkills() {
|
||||
rootClassName="border-b border-divider-subtle pt-4"
|
||||
panelContentClassName="flex flex-col gap-1 pb-4"
|
||||
actions={
|
||||
!readOnly && (
|
||||
!isViewingVersion && (
|
||||
<Popover open={addMenuOpen} onOpenChange={handleAddMenuOpenChange}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
||||
@ -16,7 +16,10 @@ import {
|
||||
agentComposerPublishedDraftAtom,
|
||||
isAgentComposerDirtyAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
|
||||
import {
|
||||
AgentOrchestrateReadOnlyContext,
|
||||
AgentOrchestrateViewingVersionContext,
|
||||
} from '../../read-only-context'
|
||||
import { AgentTools } from '../index'
|
||||
|
||||
const toolProviderState = vi.hoisted(() => ({
|
||||
@ -342,7 +345,13 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen
|
||||
}
|
||||
}
|
||||
|
||||
function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDraft) {
|
||||
function renderReadonlyAgentTools({
|
||||
initialDraft = agentToolsDraft,
|
||||
viewingVersion = false,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
viewingVersion?: boolean
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
@ -354,9 +363,11 @@ function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agent
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentComposerProvider initialDraft={initialDraft}>
|
||||
<AgentOrchestrateReadOnlyContext value>
|
||||
<AgentTools />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
|
||||
<AgentOrchestrateReadOnlyContext value>
|
||||
<AgentTools />
|
||||
</AgentOrchestrateReadOnlyContext>
|
||||
</AgentOrchestrateViewingVersionContext>
|
||||
</AgentComposerProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
@ -443,9 +454,9 @@ describe('AgentTools', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide add, edit, and remove actions when readonly', async () => {
|
||||
it('should hide add, edit, and remove actions when viewing a version', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderReadonlyAgentTools()
|
||||
renderReadonlyAgentTools({ viewingVersion: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
@ -486,6 +497,16 @@ describe('AgentTools', () => {
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep add action available for build drafts', () => {
|
||||
renderReadonlyAgentTools()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.tools.add',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should hide CLI tool rows while CLI tools are disabled', () => {
|
||||
renderAgentTools()
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ import { ConfigureSectionAddButton } from '../common/add-button'
|
||||
import { ConfigureSectionEmpty } from '../common/empty'
|
||||
import { ConfigureSection } from '../common/section'
|
||||
import { AgentConfigureTipContent } from '../common/tip-content'
|
||||
import { useAgentOrchestrateReadOnly } from '../read-only-context'
|
||||
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
|
||||
import { CliToolDialog } from './cli-tool/dialog'
|
||||
import { AgentCliToolItem } from './cli-tool/item'
|
||||
import {
|
||||
@ -400,7 +400,7 @@ function AddToolMenu({
|
||||
|
||||
export function AgentTools() {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const readOnly = useAgentOrchestrateReadOnly()
|
||||
const isViewingVersion = useAgentOrchestrateViewingVersion()
|
||||
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
|
||||
const providerById = useAgentToolProviderMap()
|
||||
const tools = useAtomValue(agentComposerToolsAtom)
|
||||
@ -533,7 +533,7 @@ export function AgentTools() {
|
||||
rootClassName="border-b border-divider-subtle pt-4"
|
||||
panelContentClassName="flex flex-col gap-1 pb-4"
|
||||
actions={
|
||||
!readOnly ? (
|
||||
!isViewingVersion ? (
|
||||
<AddToolMenu
|
||||
onAddCliTool={openCliToolDialog}
|
||||
onAddTools={addTools}
|
||||
|
||||
@ -343,6 +343,12 @@ describe('SkillsPage', () => {
|
||||
await user.click(await screen.findByText('common.operation.delete'))
|
||||
const dialog = await screen.findByRole('alertdialog')
|
||||
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
'agentV2.skillManagement.deleteDialog.referencedDescription:{"count":2}',
|
||||
),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@ -210,6 +210,13 @@ function DeleteSkillDialog({
|
||||
const deleteMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.delete.mutationOptions(),
|
||||
)
|
||||
const referenceCount = skill.reference_count ?? 0
|
||||
const description =
|
||||
referenceCount > 0
|
||||
? t(($) => $['skillManagement.deleteDialog.referencedDescription'], {
|
||||
count: referenceCount,
|
||||
})
|
||||
: t(($) => $['skillManagement.deleteDialog.description'])
|
||||
|
||||
const handleDelete = () => {
|
||||
if (deleteMutation.isPending) return
|
||||
@ -246,7 +253,7 @@ function DeleteSkillDialog({
|
||||
{t(($) => $['skillManagement.deleteDialog.title'], { name: skill.display_name })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="mt-2 system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $['skillManagement.deleteDialog.description'])}
|
||||
{description}
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogActions className="p-0 pt-6">
|
||||
<AlertDialogCancelButton disabled={deleteMutation.isPending}>
|
||||
|
||||
@ -444,6 +444,7 @@
|
||||
"skillManagement.createSuccess": "Skill created.",
|
||||
"skillManagement.dateTimeFormat": "MMM D, YYYY HH:mm",
|
||||
"skillManagement.deleteDialog.description": "This skill will be removed from the workspace. Agents that reference it may lose access to this capability.",
|
||||
"skillManagement.deleteDialog.referencedDescription": "This Skill is referenced by {{count}} Agent, Workflow, or Chatflow item. Deleting it will remove the Skill from the workspace and clear those bindings.",
|
||||
"skillManagement.deleteDialog.title": "Delete {{name}}?",
|
||||
"skillManagement.deleteFailed": "Failed to delete skill.",
|
||||
"skillManagement.deleteSuccess": "Skill deleted.",
|
||||
|
||||
@ -444,6 +444,7 @@
|
||||
"skillManagement.createSuccess": "Skill 已创建。",
|
||||
"skillManagement.dateTimeFormat": "YYYY-MM-DD HH:mm",
|
||||
"skillManagement.deleteDialog.description": "该 Skill 将从 workspace 中移除。引用它的 Agent 可能失去对应能力。",
|
||||
"skillManagement.deleteDialog.referencedDescription": "该 Skill 当前被 {{count}} 个 Agent、Workflow 或 Chatflow 引用。删除后会从 workspace 中移除该 Skill,并清理这些绑定关系。",
|
||||
"skillManagement.deleteDialog.title": "删除 {{name}}?",
|
||||
"skillManagement.deleteFailed": "Skill 删除失败。",
|
||||
"skillManagement.deleteSuccess": "Skill 已删除。",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user