mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix: fix some bugs
This commit is contained in:
parent
2f5fee324a
commit
5a13489fdd
@ -133,9 +133,21 @@ description, or metadata.display-name. Ordinary Markdown files under references/
|
||||
should contain only their own document content unless the user explicitly asks
|
||||
for YAML frontmatter in that file.
|
||||
|
||||
Use a low-friction progressive flow. If the user's intent is clear enough,
|
||||
create or revise a useful draft immediately instead of blocking on questions.
|
||||
If important details are missing, make conservative assumptions and mention at
|
||||
most one optional clarification in the reply. Every reply should include 2-3
|
||||
short suggested user replies that the UI can show as clickable chips. The
|
||||
suggestions must be concrete next refinements the user could choose, not generic
|
||||
commands.
|
||||
|
||||
Respond with JSON only:
|
||||
{
|
||||
"reply": "short user-facing summary",
|
||||
"suggestions": [
|
||||
"Use this for ecommerce refund escalation",
|
||||
"Ask me about required inputs first"
|
||||
],
|
||||
"operations": [
|
||||
{
|
||||
"operation": "upsert_text",
|
||||
@ -398,6 +410,7 @@ class SkillAssistActionPlan(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
reply: str = Field(default="", max_length=4_000)
|
||||
suggestions: list[str] = Field(default_factory=list, max_length=3)
|
||||
operations: list[SkillAssistDraftOperationPayload] = Field(default_factory=list, max_length=10)
|
||||
|
||||
|
||||
@ -788,6 +801,15 @@ class SkillManagementService:
|
||||
"answer": reply,
|
||||
}
|
||||
)
|
||||
suggestions = [suggestion.strip() for suggestion in plan.suggestions if suggestion.strip()]
|
||||
if suggestions:
|
||||
yield self._assistant_sse(
|
||||
{
|
||||
"event": "skill_assistant_suggestions",
|
||||
"id": message_id,
|
||||
"suggestions": suggestions[:3],
|
||||
}
|
||||
)
|
||||
|
||||
detail: dict[str, Any] | None = None
|
||||
applied_operations: list[dict[str, str]] = []
|
||||
@ -914,7 +936,7 @@ class SkillManagementService:
|
||||
except json.JSONDecodeError:
|
||||
parsed = json_repair.loads(raw_text)
|
||||
try:
|
||||
return SkillAssistActionPlan.model_validate(parsed)
|
||||
plan = SkillAssistActionPlan.model_validate(parsed)
|
||||
except ValidationError as exc:
|
||||
raise SkillManagementServiceError(
|
||||
"invalid_skill_assistant_response",
|
||||
@ -922,6 +944,70 @@ class SkillManagementService:
|
||||
status_code=422,
|
||||
details={"raw_response": raw_text[:2_000]},
|
||||
) from exc
|
||||
if not any(suggestion.strip() for suggestion in plan.suggestions):
|
||||
plan = plan.model_copy(
|
||||
update={
|
||||
"suggestions": self._generate_assistant_suggestions(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
user_message=message,
|
||||
assistant_reply=plan.reply,
|
||||
)
|
||||
}
|
||||
)
|
||||
return plan
|
||||
|
||||
def _generate_assistant_suggestions(
|
||||
self,
|
||||
*,
|
||||
model_instance: Any,
|
||||
model_parameters: dict[str, Any],
|
||||
user_message: str,
|
||||
assistant_reply: str,
|
||||
) -> list[str]:
|
||||
prompt = (
|
||||
"Generate 2-3 concise clickable follow-up replies the user could send next.\n"
|
||||
"They must be specific to the user's Skill Builder request and the assistant reply.\n"
|
||||
"Do not include generic actions like continue, ok, or looks good.\n"
|
||||
"Return exactly one JSON object and no markdown fences, prose, or explanation.\n"
|
||||
"Required schema: {\"suggestions\": [\"...\", \"...\"]}\n\n"
|
||||
f"User request:\n{user_message}\n\n"
|
||||
f"Assistant reply:\n{assistant_reply}"
|
||||
)
|
||||
try:
|
||||
response = model_instance.invoke_llm(
|
||||
prompt_messages=[
|
||||
SystemPromptMessage(content="You generate concise suggested user replies for Dify Skill Builder."),
|
||||
UserPromptMessage(content=prompt),
|
||||
],
|
||||
model_parameters=model_parameters,
|
||||
stream=False,
|
||||
)
|
||||
raw_text = response.message.get_text_content()
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
parsed = json_repair.loads(raw_text)
|
||||
except Exception:
|
||||
logger.warning("skill_assistant_suggestions_failed", exc_info=True)
|
||||
return []
|
||||
|
||||
if isinstance(parsed, list):
|
||||
suggestions = parsed
|
||||
elif isinstance(parsed, dict):
|
||||
suggestions = (
|
||||
parsed.get("suggestions")
|
||||
or parsed.get("suggested_replies")
|
||||
or parsed.get("follow_up_suggestions")
|
||||
or parsed.get("quick_replies")
|
||||
)
|
||||
else:
|
||||
return []
|
||||
if not isinstance(suggestions, list):
|
||||
return []
|
||||
return [suggestion.strip() for suggestion in suggestions if isinstance(suggestion, str) and suggestion.strip()][
|
||||
:3
|
||||
]
|
||||
|
||||
def _resolve_assistant_model(
|
||||
self,
|
||||
@ -1199,14 +1285,6 @@ class SkillManagementService:
|
||||
self._check_expected_updated_at(skill, payload.expected_updated_at)
|
||||
if payload.display_name is not None:
|
||||
skill.display_name = payload.display_name
|
||||
if self._should_auto_sync_name(skill):
|
||||
skill.name = self._generate_name_from_display_name(
|
||||
session,
|
||||
tenant_id=tenant_id,
|
||||
display_name=payload.display_name,
|
||||
current_skill_id=skill.id,
|
||||
)
|
||||
self._sync_skill_md_text_file(session, skill=skill)
|
||||
if payload.icon is not None:
|
||||
skill.icon = payload.icon
|
||||
if payload.tags is not None:
|
||||
|
||||
@ -404,6 +404,7 @@ def test_create_assistant_action_stream_applies_file_operations_and_returns_deta
|
||||
model_output = json.dumps(
|
||||
{
|
||||
"reply": "Created the refund policy reference.",
|
||||
"suggestions": ["Add escalation rules", "Include refund examples"],
|
||||
"operations": [
|
||||
{
|
||||
"operation": "upsert_text",
|
||||
@ -433,10 +434,16 @@ def test_create_assistant_action_stream_applies_file_operations_and_returns_deta
|
||||
)
|
||||
|
||||
events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
|
||||
assert [event["event"] for event in events] == ["message", "skill_detail_updated", "message_end"]
|
||||
assert [event["event"] for event in events] == [
|
||||
"message",
|
||||
"skill_assistant_suggestions",
|
||||
"skill_detail_updated",
|
||||
"message_end",
|
||||
]
|
||||
assert events[0]["answer"] == "Created the refund policy reference."
|
||||
assert events[1]["operations"] == [{"operation": "upsert_text", "path": "references/refund-policy.md"}]
|
||||
assert any(file["path"] == "references/refund-policy.md" for file in events[1]["detail"]["files"])
|
||||
assert events[1]["suggestions"] == ["Add escalation rules", "Include refund examples"]
|
||||
assert events[2]["operations"] == [{"operation": "upsert_text", "path": "references/refund-policy.md"}]
|
||||
assert any(file["path"] == "references/refund-policy.md" for file in events[2]["detail"]["files"])
|
||||
|
||||
draft = service.get_skill(tenant_id=TENANT, skill_id=created["id"])
|
||||
reference = next(file for file in draft["files"] if file["path"] == "references/refund-policy.md")
|
||||
@ -493,6 +500,58 @@ def test_create_assistant_action_stream_strips_skill_frontmatter_from_reference_
|
||||
assert reference["content"] == "# Refund Policy\n"
|
||||
|
||||
|
||||
def test_create_assistant_action_stream_generates_missing_suggestions() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
payload=SkillCreatePayload(name="refund-sop", description="Handle refund requests."),
|
||||
)
|
||||
model_outputs = [
|
||||
json.dumps(
|
||||
{
|
||||
"reply": "Created the refund policy reference.",
|
||||
"operations": [
|
||||
{
|
||||
"operation": "upsert_text",
|
||||
"path": "references/refund-policy.md",
|
||||
"mime_type": "text/markdown",
|
||||
"content": "# Refund Policy\n",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
json.dumps({"follow_up_suggestions": ["Add SLA tiers", "Include refund denial templates"]}),
|
||||
]
|
||||
|
||||
def invoke_llm(**_kwargs):
|
||||
return SimpleNamespace(
|
||||
message=SimpleNamespace(get_text_content=lambda: model_outputs.pop(0)),
|
||||
)
|
||||
|
||||
model = SimpleNamespace(invoke_llm=invoke_llm)
|
||||
manager = SimpleNamespace(get_default_model_instance=lambda **_kwargs: model)
|
||||
|
||||
with patch("services.skill_management_service.ModelManager.for_tenant", return_value=manager):
|
||||
response = list(
|
||||
service.create_assistant_action_stream(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
skill_id=created["id"],
|
||||
message="新建 references/refund-policy.md",
|
||||
)
|
||||
)
|
||||
|
||||
events = [json.loads(chunk.removeprefix("data: ").strip()) for chunk in response]
|
||||
assert [event["event"] for event in events] == [
|
||||
"message",
|
||||
"skill_assistant_suggestions",
|
||||
"skill_detail_updated",
|
||||
"message_end",
|
||||
]
|
||||
assert events[1]["suggestions"] == ["Add SLA tiers", "Include refund denial templates"]
|
||||
|
||||
|
||||
def test_create_assistant_action_stream_reports_skill_name_database_conflict() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(
|
||||
@ -677,9 +736,10 @@ def test_sync_assistant_model_config_updates_draft_without_active_snapshot() ->
|
||||
assert agent.active_config_has_model is True
|
||||
|
||||
|
||||
def test_update_display_name_auto_syncs_name_for_unpublished_placeholder() -> None:
|
||||
def test_update_display_name_keeps_name_and_draft_content_unchanged() -> None:
|
||||
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
|
||||
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload())
|
||||
original_skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
|
||||
|
||||
updated = service.update_metadata(
|
||||
tenant_id=TENANT,
|
||||
@ -689,11 +749,10 @@ def test_update_display_name_auto_syncs_name_for_unpublished_placeholder() -> No
|
||||
)
|
||||
|
||||
assert updated["display_name"] == "Finance Audit"
|
||||
assert updated["name"] == "finance-audit"
|
||||
assert updated["name"] == created["name"]
|
||||
assert updated["name_manually_edited"] is False
|
||||
skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
|
||||
assert "name: finance-audit" in skill_md["content"]
|
||||
assert "display-name: Finance Audit" in skill_md["content"]
|
||||
assert skill_md["content"] == original_skill_md["content"]
|
||||
|
||||
|
||||
def test_frontmatter_name_change_marks_manual_takeover_and_stops_display_name_sync() -> None:
|
||||
@ -724,7 +783,7 @@ def test_frontmatter_name_change_marks_manual_takeover_and_stops_display_name_sy
|
||||
assert updated["display_name"] == "Finance Audit"
|
||||
skill_md = next(item for item in service.get_skill(tenant_id=TENANT, skill_id=created["id"])["files"])
|
||||
assert "name: manual-name" in skill_md["content"]
|
||||
assert "display-name: Finance Audit" in skill_md["content"]
|
||||
assert "display-name: Finance Audit" not in skill_md["content"]
|
||||
|
||||
|
||||
def test_delete_unreferenced_placeholder_skill_deletes_initial_draft() -> None:
|
||||
|
||||
@ -1620,39 +1620,25 @@ describe('SkillDetailPage', () => {
|
||||
expect(screen.getByText(/skill\.skillManagement\.detail\.saveFailed/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('saves the live display name into SKILL.md before publishing', async () => {
|
||||
it('does not expose display-name editing in the SKILL.md metadata editor before publishing', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const displayNameInput = await screen.findByDisplayValue('Untitled skill')
|
||||
await user.clear(displayNameInput)
|
||||
await user.type(displayNameInput, '333333333')
|
||||
expect(await screen.findByText('name')).toBeInTheDocument()
|
||||
expect(screen.getByText('description')).toBeInTheDocument()
|
||||
expect(screen.queryByText('display-name')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'skill.skillManagement.detail.publishUpdate' }),
|
||||
)
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalled()
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringContaining('display-name: 333333333'),
|
||||
operation: 'upsert_text',
|
||||
path: 'SKILL.md',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('renames the skill from the sidebar title and keeps SKILL.md metadata in sync', async () => {
|
||||
it('renames the skill from the sidebar title without changing SKILL.md content', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@ -1675,23 +1661,17 @@ describe('SkillDetailPage', () => {
|
||||
await user.type(renameInput, 'Renamed skill{Enter}')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledWith(
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
content: expect.stringMatching(
|
||||
/name: github-actions-failure-debugging[\s\S]*display-name: Renamed skill/,
|
||||
),
|
||||
operation: 'upsert_text',
|
||||
path: 'SKILL.md',
|
||||
display_name: 'Renamed skill',
|
||||
}),
|
||||
}),
|
||||
expect.anything(),
|
||||
)
|
||||
})
|
||||
const savedContent = mocks.saveDraftFileMutationFn.mock.calls[0]?.[0].body.content
|
||||
expect(savedContent).not.toContain('name: renamed-skill')
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.skillMetadataMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.renameSkillSuccess')
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'common.operation.rename' })).toHaveTextContent(
|
||||
@ -1700,28 +1680,14 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('updates display-name from the manifest editor without changing name', async () => {
|
||||
const user = userEvent.setup()
|
||||
it('keeps display-name hidden in the SKILL.md metadata editor', async () => {
|
||||
renderSkillDetailPage()
|
||||
|
||||
const displayNameInput = await screen.findByDisplayValue('Untitled skill')
|
||||
await user.clear(displayNameInput)
|
||||
await user.type(displayNameInput, 'Editor Display Name')
|
||||
await user.tab()
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(mocks.saveDraftFileMutationFn).toHaveBeenCalled()
|
||||
},
|
||||
{ timeout: 2500 },
|
||||
)
|
||||
const savedContent = mocks.saveDraftFileMutationFn.mock.calls.at(-1)?.[0].body.content
|
||||
expect(savedContent).toMatch(
|
||||
/name: github-actions-failure-debugging[\s\S]*display-name: Editor Display Name/,
|
||||
)
|
||||
expect(savedContent).not.toContain('name: editor-display-name')
|
||||
expect(await screen.findByText('name')).toBeInTheDocument()
|
||||
expect(screen.getByText('description')).toBeInTheDocument()
|
||||
expect(screen.queryByText('display-name')).not.toBeInTheDocument()
|
||||
expect(mocks.saveDraftFileMutationFn).not.toHaveBeenCalled()
|
||||
expect(mocks.skillMetadataMutationFn).not.toHaveBeenCalled()
|
||||
expect(toast.success).toHaveBeenCalledWith('skill.skillManagement.detail.renameSkillSuccess')
|
||||
})
|
||||
|
||||
it('cancels an empty sidebar rename when the field loses focus', async () => {
|
||||
@ -1764,9 +1730,12 @@ describe('SkillDetailPage', () => {
|
||||
expect(publishButton).toBeDisabled()
|
||||
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.published')
|
||||
|
||||
const displayNameInput = screen.getByDisplayValue('Untitled skill')
|
||||
await user.clear(displayNameInput)
|
||||
await user.type(displayNameInput, 'Updated skill')
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.markdownSourceMode',
|
||||
}),
|
||||
)
|
||||
await user.type(getSourceEditor(), '\nUpdated published instructions')
|
||||
|
||||
expect(publishButton).toBeEnabled()
|
||||
expect(publishButton).toHaveAccessibleName('skill.skillManagement.detail.publishUpdate')
|
||||
@ -2419,6 +2388,38 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('checks references before publishing when the cached reference count is stale', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 0 })
|
||||
mocks.skillReferencesQueryOptions.mockImplementation((options) => ({
|
||||
queryKey: ['skill-references', options],
|
||||
queryFn: async () => ({
|
||||
data: [
|
||||
createAgentReference({
|
||||
display_name: 'Stale Count Agent',
|
||||
name: 'stale-count-agent',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}))
|
||||
|
||||
renderSkillDetailPage()
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', {
|
||||
name: 'skill.skillManagement.detail.publishUpdate',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByRole('dialog', {
|
||||
name: 'skill.skillManagement.detail.publishReferencesTitle',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
expect(await screen.findByText('Stale Count Agent')).toBeInTheDocument()
|
||||
expect(mocks.publishSkillMutationFn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels publishing from the referenced skill confirmation dialog', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createSkillDetail({ reference_count: 1 })
|
||||
@ -3282,12 +3283,6 @@ describe('SkillDetailPage', () => {
|
||||
await screen.findByPlaceholderText('skill.skillManagement.detail.builder.modifyPlaceholder'),
|
||||
).toBeDisabled()
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.followUpDisplayName',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@ -3389,8 +3384,12 @@ describe('SkillDetailPage', () => {
|
||||
it('sends Skill Builder follow-up suggestions after an assistant reply', async () => {
|
||||
const user = userEvent.setup()
|
||||
mocks.skillDetail = createDefaultSkillDraftDetail()
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData }) => {
|
||||
mocks.sendSkillAssistMessage.mockImplementation(({ onCompleted, onData, onUnhandledEvent }) => {
|
||||
onData?.('Drafted the skill.', true, {})
|
||||
onUnhandledEvent?.({
|
||||
event: 'skill_assistant_suggestions',
|
||||
suggestions: ['Ask me about required inputs first'],
|
||||
})
|
||||
onCompleted?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
@ -3406,14 +3405,14 @@ describe('SkillDetailPage', () => {
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', {
|
||||
name: 'skill.skillManagement.detail.builder.followUpDisplayName',
|
||||
name: 'Ask me about required inputs first',
|
||||
}),
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.sendSkillAssistMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: 'skill.skillManagement.detail.builder.followUpDisplayName',
|
||||
message: 'Ask me about required inputs first',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import {
|
||||
createUploadItemId,
|
||||
deriveSkillDetailFromDraftFiles,
|
||||
getErrorCode,
|
||||
getErrorDetailNumber,
|
||||
getErrorDetailString,
|
||||
@ -8,6 +11,7 @@ import {
|
||||
getUploadPath,
|
||||
isEditableKeyboardTarget,
|
||||
joinSkillPath,
|
||||
setSkillDetailCache,
|
||||
} from '../shared'
|
||||
|
||||
describe('skill detail shared utilities', () => {
|
||||
@ -86,4 +90,116 @@ describe('skill detail shared utilities', () => {
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('does not derive entity name or display name from SKILL.md draft content', () => {
|
||||
expect(
|
||||
deriveSkillDetailFromDraftFiles({
|
||||
description: 'Entity description',
|
||||
display_name: 'Entity Display Name',
|
||||
files: [
|
||||
{
|
||||
content:
|
||||
'---\nname: skill-md-name\ndescription: Skill.md description\nmetadata:\n display-name: Skill.md Display Name\n---\n# Body\n',
|
||||
kind: 'file',
|
||||
mime_type: 'text/markdown',
|
||||
path: 'SKILL.md',
|
||||
storage: 'text',
|
||||
},
|
||||
],
|
||||
latest_published_version_id: null,
|
||||
name: 'entity-name',
|
||||
name_manually_edited: false,
|
||||
} as Parameters<typeof deriveSkillDetailFromDraftFiles>[0]),
|
||||
).toMatchObject({
|
||||
description: 'Skill.md description',
|
||||
display_name: 'Entity Display Name',
|
||||
name: 'entity-name',
|
||||
})
|
||||
})
|
||||
|
||||
it('derives untitled builder draft name and display name from SKILL.md content', () => {
|
||||
expect(
|
||||
deriveSkillDetailFromDraftFiles({
|
||||
description: 'Entity description',
|
||||
display_name: 'Untitled skill',
|
||||
files: [
|
||||
{
|
||||
content:
|
||||
'---\nname: untitled-skill-12345678\ndescription: Skill.md description\nmetadata:\n display-name: Untitled skill\n---\n# Customer Issue Triage\n',
|
||||
kind: 'file',
|
||||
mime_type: 'text/markdown',
|
||||
path: 'SKILL.md',
|
||||
storage: 'text',
|
||||
},
|
||||
],
|
||||
latest_published_version_id: null,
|
||||
name: 'untitled-skill-12345678',
|
||||
name_manually_edited: false,
|
||||
} as Parameters<typeof deriveSkillDetailFromDraftFiles>[0]),
|
||||
).toMatchObject({
|
||||
description: 'Skill.md description',
|
||||
display_name: 'Customer Issue Triage',
|
||||
name: 'customer-issue-triage',
|
||||
})
|
||||
})
|
||||
|
||||
it('updates cached skill list entries when detail cache changes', () => {
|
||||
const queryClient = new QueryClient()
|
||||
const detail = {
|
||||
id: 'skill-1',
|
||||
name: 'customer-issue-triage',
|
||||
display_name: 'Customer Issue Triage',
|
||||
icon: '📄',
|
||||
description: 'Classify customer issues.',
|
||||
tags: [],
|
||||
name_manually_edited: false,
|
||||
visibility: 'workspace',
|
||||
latest_published_version_id: null,
|
||||
latest_published_version_number: null,
|
||||
latest_published_at: null,
|
||||
reference_count: 0,
|
||||
created_by: 'user-1',
|
||||
created_by_name: 'Fate',
|
||||
updated_by: 'user-1',
|
||||
updated_by_name: 'Fate',
|
||||
created_at: 1,
|
||||
updated_at: 2,
|
||||
files: [],
|
||||
} as Parameters<typeof setSkillDetailCache>[2]
|
||||
const infiniteKey = consoleQuery.workspaces.current.skills.get.key({ type: 'infinite' })
|
||||
queryClient.setQueryData(infiniteKey, {
|
||||
pageParams: [1],
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
...detail,
|
||||
name: 'untitled-skill-12345678',
|
||||
display_name: 'Untitled skill',
|
||||
latest_published_version_number: null,
|
||||
},
|
||||
],
|
||||
has_more: false,
|
||||
limit: 20,
|
||||
page: 1,
|
||||
total: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
setSkillDetailCache(queryClient, detail.id, detail)
|
||||
|
||||
expect(queryClient.getQueryData(infiniteKey)).toMatchObject({
|
||||
pages: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
display_name: 'Customer Issue Triage',
|
||||
name: 'customer-issue-triage',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -222,16 +222,10 @@ export function SkillBuilderPanel({
|
||||
t(($) => $['skillManagement.detail.builder.exampleSalesFollowUp']),
|
||||
t(($) => $['skillManagement.detail.builder.exampleOnboarding']),
|
||||
]
|
||||
const followUpSuggestions = [
|
||||
t(($) => $['skillManagement.detail.builder.followUpNameIcon']),
|
||||
t(($) => $['skillManagement.detail.builder.followUpDisplayName']),
|
||||
t(($) => $['skillManagement.detail.builder.exampleIssueTriage']),
|
||||
]
|
||||
const inputPlaceholder =
|
||||
messages.length > 0
|
||||
? t(($) => $['skillManagement.detail.builder.modifyPlaceholder'])
|
||||
: t(($) => $['skillManagement.detail.builder.placeholder'])
|
||||
const hasBuilderConversation = messages.some((message) => message.role === 'user')
|
||||
|
||||
const updateMessages = (
|
||||
updater: (currentMessages: BuilderChatMessage[]) => BuilderChatMessage[],
|
||||
@ -460,6 +454,24 @@ export function SkillBuilderPanel({
|
||||
)
|
||||
},
|
||||
onUnhandledEvent: (event) => {
|
||||
if (event.event === 'skill_assistant_suggestions') {
|
||||
const nextSuggestions = Array.isArray(event.suggestions)
|
||||
? event.suggestions.filter(
|
||||
(suggestion): suggestion is string => typeof suggestion === 'string',
|
||||
)
|
||||
: []
|
||||
updateMessages((currentMessages) =>
|
||||
currentMessages.map((message) =>
|
||||
message.id === assistantMessageId
|
||||
? {
|
||||
...message,
|
||||
suggestions: nextSuggestions,
|
||||
}
|
||||
: message,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.event !== 'skill_detail_updated' || !isRecord(event.detail)) return
|
||||
|
||||
const nextDetail = event.detail as SkillDetailResponse
|
||||
@ -579,8 +591,18 @@ export function SkillBuilderPanel({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 flex min-h-0 flex-1 flex-col">
|
||||
<div className="min-h-0 flex-1 scrollbar-thin overflow-y-auto px-4 pt-4 pb-[11px]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex min-h-0 flex-1 flex-col',
|
||||
messages.length === 0 && 'justify-center',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'min-h-0 scrollbar-thin overflow-y-auto px-4 pt-4 pb-[11px]',
|
||||
messages.length > 0 ? 'flex-1' : 'shrink-0',
|
||||
)}
|
||||
>
|
||||
{messages.length > 0 ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
{messages.map((message, messageIndex) =>
|
||||
@ -653,6 +675,23 @@ export function SkillBuilderPanel({
|
||||
<span aria-hidden className="i-ri-restart-line size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{!!message.suggestions?.length && (
|
||||
<div className="mt-3 flex w-full flex-wrap items-end justify-end gap-1 py-2">
|
||||
{message.suggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="max-w-full cursor-pointer rounded-md border-[0.5px] border-divider-subtle bg-background-default px-2 py-1 text-right system-xs-medium text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={
|
||||
isSending || isUploadingAttachment || !canSendBuilderMessage
|
||||
}
|
||||
onClick={() => handleSend(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : message.thinkingDurationSeconds === undefined ? (
|
||||
<SkillBuilderThinkingMessage seconds={thinkingElapsedSeconds} />
|
||||
@ -660,36 +699,21 @@ export function SkillBuilderPanel({
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
{hasBuilderConversation && (
|
||||
<div className="flex w-full flex-wrap items-end justify-end gap-1 py-2">
|
||||
{followUpSuggestions.map((suggestion) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
className="max-w-full cursor-pointer rounded-md border-[0.5px] border-divider-subtle bg-background-default px-2 py-1 text-right system-xs-medium text-text-secondary shadow-xs outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSending || isUploadingAttachment || !canSendBuilderMessage}
|
||||
onClick={() => handleSend(suggestion)}
|
||||
>
|
||||
{suggestion}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-full flex-col justify-end">
|
||||
<div className="mb-[27px] flex flex-col items-start px-3 text-left">
|
||||
<div className="flex flex-col px-3 text-left">
|
||||
<div className="mb-7 flex flex-col items-start">
|
||||
<div className="mb-4">
|
||||
<SkillBuilderEmptyIcon />
|
||||
</div>
|
||||
<h3 className="system-sm-semibold text-text-secondary">
|
||||
<h3 className="system-md-semibold text-text-secondary">
|
||||
{t(($) => $['skillManagement.detail.builder.promptTitle'])}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-64 system-xs-regular text-text-tertiary">
|
||||
<p className="mt-1 max-w-[280px] system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['skillManagement.detail.builder.promptDescription'])}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1.5 px-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="shrink-0 system-2xs-semibold-uppercase text-text-quaternary">
|
||||
{t(($) => $['skillManagement.detail.builder.tryExample'])}
|
||||
@ -713,7 +737,13 @@ export function SkillBuilderPanel({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative flex shrink-0 items-end justify-end bg-gradient-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2 px-4 pt-3 pb-4">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex shrink-0 items-end justify-end px-4 pt-3 pb-4',
|
||||
messages.length > 0 &&
|
||||
'bg-gradient-to-b from-components-chat-input-bg-mask-1 to-components-chat-input-bg-mask-2',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full flex-col items-end justify-end">
|
||||
<div className="relative flex w-full flex-col items-start overflow-hidden rounded-xl border border-components-chat-input-border bg-background-default p-3 shadow-lg">
|
||||
<input
|
||||
|
||||
@ -73,7 +73,6 @@ import {
|
||||
replaceMarkdownBody,
|
||||
runSkillFileMutation,
|
||||
serializeMarkdownLiveEditorNode,
|
||||
setMarkdownDisplayName,
|
||||
setMarkdownFrontmatterField,
|
||||
setMarkdownLiveEditorSelectionOffset,
|
||||
setSkillDetailCache,
|
||||
@ -138,7 +137,6 @@ export function FileEditor({
|
||||
const [draftContent, setDraftContent] = useState(initialContent)
|
||||
const [markdownMode, setMarkdownMode] = useState<'live' | 'source'>('live')
|
||||
const [metadataAdding, setMetadataAdding] = useState(false)
|
||||
const [displayNameDraft, setDisplayNameDraft] = useState('')
|
||||
const [metadataKey, setMetadataKey] = useState('')
|
||||
const [metadataValue, setMetadataValue] = useState('')
|
||||
const [referencePicker, setReferencePicker] = useState<{
|
||||
@ -162,8 +160,6 @@ export function FileEditor({
|
||||
const metadataKeyInputRef = useRef<HTMLInputElement>(null)
|
||||
const metadataKeyDraftRef = useRef('')
|
||||
const metadataValueDraftRef = useRef('')
|
||||
const pendingDisplayNameRenameRef = useRef(false)
|
||||
const displayNameDraftRef = useRef(displayNameDraft)
|
||||
const liveBodyTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const liveBodyEditorRef = useRef<HTMLDivElement>(null)
|
||||
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@ -198,7 +194,6 @@ export function FileEditor({
|
||||
const hasPublishedVersion = !!detail?.latest_published_version_id
|
||||
const latestPublishedAt = detail?.latest_published_at
|
||||
const hasUnpublishedChanges =
|
||||
displayNameDraft !== markdownContent.displayName ||
|
||||
saveStatus === 'dirty' ||
|
||||
saveStatus === 'saving' ||
|
||||
saveStatus === 'error' ||
|
||||
@ -227,7 +222,6 @@ export function FileEditor({
|
||||
isSkillManifestFile &&
|
||||
(markdownContent.name ||
|
||||
markdownContent.description ||
|
||||
markdownContent.displayName ||
|
||||
markdownContent.metadata.length > 0 ||
|
||||
!readonly)
|
||||
const referenceQuery = referencePicker?.query.trim().toLowerCase() ?? ''
|
||||
@ -245,11 +239,6 @@ export function FileEditor({
|
||||
})
|
||||
}, [referencePicker?.currentDirectory, referenceQuery, referenceTargets])
|
||||
|
||||
useEffect(() => {
|
||||
displayNameDraftRef.current = markdownContent.displayName
|
||||
setDisplayNameDraft(markdownContent.displayName)
|
||||
}, [markdownContent.displayName])
|
||||
|
||||
useEffect(() => {
|
||||
if (editableDraftContent === draftContent) return
|
||||
|
||||
@ -330,8 +319,6 @@ export function FileEditor({
|
||||
|
||||
setHasSaveConflict(false)
|
||||
setSaveStatus('saving')
|
||||
const shouldNotifyDisplayNameRename =
|
||||
currentFile.path === 'SKILL.md' && pendingDisplayNameRenameRef.current
|
||||
try {
|
||||
const nextCachedDetail = await runSkillFileMutation(
|
||||
fileMutationCoordinator,
|
||||
@ -362,10 +349,6 @@ export function FileEditor({
|
||||
setSaveStatus(draftContentRef.current === content ? 'saved' : 'dirty')
|
||||
setSkillDetailCache(queryClient, skillId, nextCachedDetail)
|
||||
onDraftDetailChange(nextCachedDetail)
|
||||
if (shouldNotifyDisplayNameRename) {
|
||||
pendingDisplayNameRenameRef.current = false
|
||||
toast.success(t(($) => $['skillManagement.detail.renameSkillSuccess']))
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
const errorPayload = await getAsyncSkillErrorPayload(error)
|
||||
@ -868,14 +851,6 @@ export function FileEditor({
|
||||
setMetadataAdding(false)
|
||||
}
|
||||
|
||||
const handleDisplayNameCommit = () => {
|
||||
const nextDisplayName = displayNameDraftRef.current
|
||||
if (!isSkillManifestFile || readonly || nextDisplayName === markdownContent.displayName) return
|
||||
|
||||
pendingDisplayNameRenameRef.current = true
|
||||
updateDraftContent(setMarkdownDisplayName(draftContentRef.current, nextDisplayName))
|
||||
}
|
||||
|
||||
const handleRemoveMetadata = (key: string) => {
|
||||
if (!isSkillManifestFile) return
|
||||
|
||||
@ -895,18 +870,29 @@ export function FileEditor({
|
||||
return
|
||||
}
|
||||
|
||||
let contentToPublish = draftContentRef.current
|
||||
if (canEdit && isSkillManifestFile && displayNameDraft !== markdownContent.displayName) {
|
||||
contentToPublish = setMarkdownDisplayName(contentToPublish, displayNameDraft)
|
||||
updateDraftContent(contentToPublish)
|
||||
}
|
||||
const contentToPublish = draftContentRef.current
|
||||
|
||||
if (canEdit && contentToPublish !== lastSavedContentRef.current) {
|
||||
const saved = await saveDraftContent(contentToPublish)
|
||||
if (!saved) return
|
||||
}
|
||||
|
||||
if ((detail?.reference_count ?? 0) > 0) {
|
||||
const referenceCount = detail?.reference_count ?? 0
|
||||
if (referenceCount > 0) {
|
||||
setPublishConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
const references = await queryClient.fetchQuery(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.references.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
skill_id: skillId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if ((references.data?.length ?? 0) > 0) {
|
||||
setPublishConfirmOpen(true)
|
||||
return
|
||||
}
|
||||
@ -915,14 +901,12 @@ export function FileEditor({
|
||||
}, [
|
||||
canEdit,
|
||||
detail?.reference_count,
|
||||
displayNameDraft,
|
||||
isSkillManifestFile,
|
||||
markdownContent.displayName,
|
||||
onPublish,
|
||||
publishDisabled,
|
||||
queryClient,
|
||||
saveDraftContent,
|
||||
saveStatus,
|
||||
updateDraftContent,
|
||||
skillId,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
@ -1056,23 +1040,6 @@ export function FileEditor({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{(markdownContent.displayName || !readonly) && (
|
||||
<EditableMetadataField
|
||||
label="display-name"
|
||||
value={displayNameDraft}
|
||||
valuePlaceholder={detail?.display_name ?? ''}
|
||||
readOnly={readonly}
|
||||
onBlurCapture={handleDisplayNameCommit}
|
||||
onValueChange={
|
||||
readonly
|
||||
? undefined
|
||||
: (nextDisplayName) => {
|
||||
displayNameDraftRef.current = nextDisplayName
|
||||
setDisplayNameDraft(nextDisplayName)
|
||||
}
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{markdownContent.metadata.map((entry) => {
|
||||
const removable = !readonly && !isProtectedMarkdownMetadataKey(entry.key)
|
||||
|
||||
|
||||
@ -3,9 +3,10 @@
|
||||
import type {
|
||||
SkillDetailResponse,
|
||||
SkillFileResponse,
|
||||
SkillResponse,
|
||||
SkillVersionResponse,
|
||||
} from '@dify/contracts/api/console/workspaces/types.gen'
|
||||
import type { useQueryClient } from '@tanstack/react-query'
|
||||
import type { InfiniteData, useQueryClient } from '@tanstack/react-query'
|
||||
import type {
|
||||
DefaultModel,
|
||||
FormValue,
|
||||
@ -141,6 +142,7 @@ export type BuilderChatMessage = {
|
||||
id: string
|
||||
rawContent?: string
|
||||
role: 'assistant' | 'user'
|
||||
suggestions?: string[]
|
||||
thinkingDurationSeconds?: number
|
||||
tone?: 'error'
|
||||
}
|
||||
@ -182,6 +184,8 @@ const defaultSkillDescription = 'Describe what this Skill does and when an Agent
|
||||
const defaultSkillBody =
|
||||
'# Untitled skill\n\nDescribe what this Skill does, when an Agent should use it, and any step-by-step instructions it must follow.'
|
||||
|
||||
const untitledSkillDisplayName = 'Untitled skill'
|
||||
|
||||
const emptySkillDraftContentPlaceholder = '<!-- dify-skill-empty-draft -->'
|
||||
|
||||
function isLegacyUntitledSkillDraftContent(content: string) {
|
||||
@ -1318,7 +1322,7 @@ export function isDefaultSkillBuilderDraft(detail: SkillDetailResponse) {
|
||||
return (
|
||||
detail.latest_published_version_id == null &&
|
||||
detail.name.startsWith('untitled-skill') &&
|
||||
detail.display_name === 'Untitled skill' &&
|
||||
detail.display_name === untitledSkillDisplayName &&
|
||||
(description === '' || description === defaultSkillDescription) &&
|
||||
(skillMdBody === '' || skillMdBody === defaultSkillBody)
|
||||
)
|
||||
@ -1329,15 +1333,52 @@ export function deriveSkillDetailFromDraftFiles(detail: SkillDetailResponse) {
|
||||
if (!skillMd || !isTextFile(skillMd) || !skillMd.content) return detail
|
||||
|
||||
const parsedSkillMd = parseMarkdownContent(skillMd.content)
|
||||
const shouldDeriveUntitledSkillName =
|
||||
detail.latest_published_version_id == null &&
|
||||
!detail.name_manually_edited &&
|
||||
detail.name.startsWith('untitled-skill') &&
|
||||
detail.display_name === untitledSkillDisplayName
|
||||
const derivedDisplayName = shouldDeriveUntitledSkillName
|
||||
? getDraftSkillDisplayName(parsedSkillMd)
|
||||
: undefined
|
||||
const derivedName = derivedDisplayName
|
||||
? getDraftSkillName(parsedSkillMd, derivedDisplayName)
|
||||
: undefined
|
||||
|
||||
return {
|
||||
...detail,
|
||||
description: parsedSkillMd.description || detail.description,
|
||||
display_name: parsedSkillMd.displayName || detail.display_name,
|
||||
name: parsedSkillMd.name || detail.name,
|
||||
...(derivedDisplayName ? { display_name: derivedDisplayName } : {}),
|
||||
...(derivedName ? { name: derivedName } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function getDraftSkillDisplayName(parsedSkillMd: ParsedMarkdownContent) {
|
||||
if (parsedSkillMd.displayName && parsedSkillMd.displayName !== untitledSkillDisplayName)
|
||||
return parsedSkillMd.displayName
|
||||
|
||||
const headingLine = parsedSkillMd.body
|
||||
.split('\n')
|
||||
.find((line) => line.startsWith('# ') && line.slice(2).trim())
|
||||
const heading = headingLine?.slice(2).trim()
|
||||
if (!heading || heading === untitledSkillDisplayName) return undefined
|
||||
|
||||
return heading
|
||||
}
|
||||
|
||||
function getDraftSkillName(parsedSkillMd: ParsedMarkdownContent, displayName: string) {
|
||||
if (parsedSkillMd.name && !parsedSkillMd.name.startsWith('untitled-skill'))
|
||||
return parsedSkillMd.name
|
||||
|
||||
const generatedName = displayName
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
|
||||
return generatedName || undefined
|
||||
}
|
||||
|
||||
function parseServerMessage(message: string) {
|
||||
const trimmedMessage = message.trim()
|
||||
if (!trimmedMessage.startsWith('{')) return trimmedMessage
|
||||
@ -1492,6 +1533,64 @@ export function setSkillDetailCache(
|
||||
}),
|
||||
detail,
|
||||
)
|
||||
updateSkillListCache(queryClient, detail)
|
||||
}
|
||||
|
||||
type SkillListCachePage = {
|
||||
data?: SkillResponse[]
|
||||
}
|
||||
|
||||
function updateSkillListCache(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
detail: SkillDetailResponse,
|
||||
) {
|
||||
const updateSkill = (skill: SkillResponse): SkillResponse => {
|
||||
if (skill.id !== detail.id) return skill
|
||||
|
||||
return {
|
||||
...skill,
|
||||
description: detail.description,
|
||||
display_name: detail.display_name,
|
||||
icon: detail.icon,
|
||||
latest_published_at: detail.latest_published_at,
|
||||
latest_published_version_id: detail.latest_published_version_id,
|
||||
latest_published_version_number: detail.latest_published_version_number,
|
||||
name: detail.name,
|
||||
name_manually_edited: detail.name_manually_edited,
|
||||
reference_count: detail.reference_count,
|
||||
tags: detail.tags,
|
||||
updated_at: detail.updated_at,
|
||||
updated_by: detail.updated_by,
|
||||
updated_by_name: detail.updated_by_name,
|
||||
}
|
||||
}
|
||||
const updatePage = <TPage extends SkillListCachePage>(page: TPage): TPage => {
|
||||
if (!page.data?.some((skill) => skill.id === detail.id)) return page
|
||||
|
||||
return {
|
||||
...page,
|
||||
data: page.data.map(updateSkill),
|
||||
}
|
||||
}
|
||||
|
||||
queryClient.setQueriesData<SkillListCachePage>(
|
||||
{
|
||||
queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'query' }),
|
||||
},
|
||||
(page) => (page ? updatePage(page) : page),
|
||||
)
|
||||
queryClient.setQueriesData<InfiniteData<SkillListCachePage>>(
|
||||
{
|
||||
queryKey: consoleQuery.workspaces.current.skills.get.key({ type: 'infinite' }),
|
||||
},
|
||||
(cache) =>
|
||||
cache
|
||||
? {
|
||||
...cache,
|
||||
pages: cache.pages.map(updatePage),
|
||||
}
|
||||
: cache,
|
||||
)
|
||||
}
|
||||
|
||||
function refetchSkillDetail(skillId: string) {
|
||||
|
||||
@ -7,14 +7,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchSkillFileBlob } from '../client'
|
||||
import {
|
||||
findFileByPath,
|
||||
invalidateSkillDetail,
|
||||
runSkillFileMutation,
|
||||
setMarkdownDisplayName,
|
||||
setSkillDetailCache,
|
||||
} from './shared'
|
||||
import { invalidateSkillDetail, runSkillFileMutation, setSkillDetailCache } from './shared'
|
||||
|
||||
export function SkillDisplayNameEditor({
|
||||
detail,
|
||||
@ -38,12 +31,12 @@ export function SkillDisplayNameEditor({
|
||||
next: string
|
||||
}>()
|
||||
const [draftName, setDraftName] = useState(detail.display_name)
|
||||
const fileMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.files.patch.mutationOptions({
|
||||
const metadataMutation = useMutation(
|
||||
consoleQuery.workspaces.current.skills.bySkillId.patch.mutationOptions({
|
||||
context: { silent: true },
|
||||
}),
|
||||
)
|
||||
const saving = fileMutation.isPending
|
||||
const saving = metadataMutation.isPending
|
||||
const displayName =
|
||||
displayNameOverride?.base === detail.display_name
|
||||
? displayNameOverride.next
|
||||
@ -70,39 +63,18 @@ export function SkillDisplayNameEditor({
|
||||
return
|
||||
}
|
||||
|
||||
const skillFile = findFileByPath(detail.files ?? [], 'SKILL.md')
|
||||
if (!skillFile) {
|
||||
toast.error(t(($) => $['skillManagement.detail.fileMissing']))
|
||||
return
|
||||
}
|
||||
|
||||
submittingRef.current = true
|
||||
try {
|
||||
const currentContent =
|
||||
skillFile.content ??
|
||||
(await (
|
||||
await fetchSkillFileBlob({
|
||||
path: skillFile.path,
|
||||
skillId,
|
||||
versionId: null,
|
||||
})
|
||||
).text())
|
||||
const nextContent = setMarkdownDisplayName(currentContent, nextDisplayName)
|
||||
const nextDetail = await runSkillFileMutation(
|
||||
fileMutationCoordinator,
|
||||
async (expectedUpdatedAt) => {
|
||||
return fileMutation.mutateAsync({
|
||||
return metadataMutation.mutateAsync({
|
||||
params: {
|
||||
skill_id: skillId,
|
||||
},
|
||||
body: {
|
||||
content: nextContent,
|
||||
display_name: nextDisplayName,
|
||||
expected_updated_at: expectedUpdatedAt,
|
||||
hash: skillFile.hash,
|
||||
mime_type: skillFile.mime_type,
|
||||
operation: 'upsert_text',
|
||||
path: skillFile.path,
|
||||
size: new Blob([nextContent]).size,
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
@ -49,7 +49,7 @@ function UploadRowShell({
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
|
||||
{!!actions && <div className="flex shrink-0 items-center gap-1">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user