mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 00:31:19 +08:00
feat: update publish button logic
This commit is contained in:
parent
ce38c21253
commit
51b668a2a6
@ -87,6 +87,8 @@ class SkillResponse(ResponseModel):
|
||||
name_manually_edited: bool = False
|
||||
visibility: str
|
||||
latest_published_version_id: str | None = None
|
||||
latest_published_version_number: int | None = None
|
||||
latest_published_at: int | None = None
|
||||
reference_count: int = 0
|
||||
created_by: str | None = None
|
||||
created_by_name: str | None = None
|
||||
|
||||
@ -31,6 +31,7 @@ import yaml
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||
from sqlalchemy.orm import object_session
|
||||
from yaml.error import MarkedYAMLError
|
||||
|
||||
from core.db.session_factory import session_factory
|
||||
@ -1949,6 +1950,15 @@ class SkillManagementService:
|
||||
accounts = accounts or {}
|
||||
created_by_account = accounts.get(skill.created_by or "")
|
||||
updated_by_account = accounts.get(skill.updated_by or "")
|
||||
latest_published_version_number: int | None = None
|
||||
latest_published_at: int | None = None
|
||||
session = object_session(skill)
|
||||
if session is not None and skill.latest_published_version_id is not None:
|
||||
latest_version = session.get(SkillVersion, skill.latest_published_version_id)
|
||||
if latest_version is not None:
|
||||
latest_published_version_number = latest_version.version_number
|
||||
latest_published_at = int(latest_version.created_at.timestamp())
|
||||
|
||||
return {
|
||||
"id": skill.id,
|
||||
"name": skill.name,
|
||||
@ -1959,6 +1969,8 @@ class SkillManagementService:
|
||||
"name_manually_edited": skill.name_manually_edited,
|
||||
"visibility": skill.visibility,
|
||||
"latest_published_version_id": skill.latest_published_version_id,
|
||||
"latest_published_version_number": latest_published_version_number,
|
||||
"latest_published_at": latest_published_at,
|
||||
"reference_count": reference_count,
|
||||
"created_by": skill.created_by,
|
||||
"created_by_name": created_by_account.name if created_by_account else None,
|
||||
|
||||
@ -671,7 +671,9 @@ export type SkillDetailResponse = {
|
||||
files?: Array<SkillFileResponse>
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_at?: number | null
|
||||
latest_published_version_id?: string | null
|
||||
latest_published_version_number?: number | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
@ -718,7 +720,9 @@ export type SkillResponse = {
|
||||
display_name: string
|
||||
icon: string
|
||||
id: string
|
||||
latest_published_at?: number | null
|
||||
latest_published_version_id?: string | null
|
||||
latest_published_version_number?: number | null
|
||||
name: string
|
||||
name_manually_edited?: boolean
|
||||
reference_count?: number
|
||||
|
||||
@ -502,7 +502,9 @@ export const zSkillResponse = z.object({
|
||||
display_name: z.string(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_at: z.int().nullish(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
latest_published_version_number: z.int().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
@ -1455,7 +1457,9 @@ export const zSkillDetailResponse = z.object({
|
||||
files: z.array(zSkillFileResponse).optional(),
|
||||
icon: z.string(),
|
||||
id: z.string(),
|
||||
latest_published_at: z.int().nullish(),
|
||||
latest_published_version_id: z.string().nullish(),
|
||||
latest_published_version_number: z.int().nullish(),
|
||||
name: z.string(),
|
||||
name_manually_edited: z.boolean().optional().default(false),
|
||||
reference_count: z.int().optional().default(0),
|
||||
|
||||
@ -210,6 +210,8 @@ function createSkillDetail(overrides: Partial<SkillDetailResponse> = {}): SkillD
|
||||
name_manually_edited: true,
|
||||
visibility: 'workspace',
|
||||
latest_published_version_id: 'version-1',
|
||||
latest_published_version_number: 1,
|
||||
latest_published_at: 1784638400,
|
||||
reference_count: 0,
|
||||
created_by: 'user-1',
|
||||
created_by_name: 'Fate',
|
||||
@ -461,17 +463,29 @@ describe('SkillDetailPage', () => {
|
||||
return nextDetail
|
||||
},
|
||||
)
|
||||
mocks.publishSkillMutationFn.mockResolvedValue({
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
publish_note: '',
|
||||
hash_code: 'hash-code',
|
||||
archive_size: 180,
|
||||
published_by: 'user-1',
|
||||
published_by_name: 'Fate',
|
||||
created_at: 1784638492,
|
||||
is_latest: true,
|
||||
mocks.publishSkillMutationFn.mockImplementation(async () => {
|
||||
const version = {
|
||||
id: 'version-2',
|
||||
version_number: 2,
|
||||
version_name: '',
|
||||
publish_note: '',
|
||||
hash_code: 'hash-code',
|
||||
archive_size: 180,
|
||||
published_by: 'user-1',
|
||||
published_by_name: 'Fate',
|
||||
created_at: 1784638492,
|
||||
is_latest: true,
|
||||
}
|
||||
mocks.skillDetail = mocks.skillDetail
|
||||
? {
|
||||
...mocks.skillDetail,
|
||||
latest_published_at: version.created_at,
|
||||
latest_published_version_id: version.id,
|
||||
latest_published_version_number: version.version_number,
|
||||
updated_at: version.created_at,
|
||||
}
|
||||
: mocks.skillDetail
|
||||
return version
|
||||
})
|
||||
mocks.restoreSkillMutationFn.mockResolvedValue({})
|
||||
mocks.versionPatchMutationFn.mockResolvedValue({})
|
||||
@ -731,6 +745,34 @@ describe('SkillDetailPage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('marks the draft as published and disables publish until new edits are made', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
const publishButton = await screen.findByRole('button', {
|
||||
name: 'agentV2.skillManagement.detail.publish',
|
||||
})
|
||||
expect(publishButton).toBeEnabled()
|
||||
|
||||
await user.click(publishButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.publishSkillMutationFn).toHaveBeenCalled()
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(document.body).toHaveTextContent(
|
||||
'agentV2.skillManagement.detail.publishedVersion:{"number":2}',
|
||||
)
|
||||
})
|
||||
expect(publishButton).toBeDisabled()
|
||||
|
||||
const displayNameInput = screen.getByDisplayValue('Untitled skill')
|
||||
await user.clear(displayNameInput)
|
||||
await user.type(displayNameInput, 'Updated skill')
|
||||
|
||||
expect(publishButton).toBeEnabled()
|
||||
})
|
||||
|
||||
it('adds custom metadata from the value field Enter key and saves it on publish', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderSkillDetailPage()
|
||||
|
||||
@ -4170,6 +4170,8 @@ function FileEditor({
|
||||
detail,
|
||||
file,
|
||||
fileMutationCoordinator,
|
||||
hasLocalUnpublishedChanges,
|
||||
onLocalUnpublishedChangesChange,
|
||||
onOpenVersions,
|
||||
onPublish,
|
||||
onRestoreVersion,
|
||||
@ -4188,6 +4190,8 @@ function FileEditor({
|
||||
detail: SkillDetailResponse | undefined
|
||||
file: SkillFileResponse | undefined
|
||||
fileMutationCoordinator: SkillFileMutationCoordinator
|
||||
hasLocalUnpublishedChanges: boolean
|
||||
onLocalUnpublishedChangesChange: (hasChanges: boolean) => void
|
||||
onOpenVersions: () => void
|
||||
onPublish: () => void
|
||||
onRestoreVersion: () => void
|
||||
@ -4231,6 +4235,7 @@ function FileEditor({
|
||||
const saveConflictContentRef = useRef<string | null>(null)
|
||||
const detailRef = useRef(detail)
|
||||
const fileRef = useRef(file)
|
||||
const pendingPublishAfterSaveRef = useRef(false)
|
||||
const liveBodyTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const liveBodyEditorRef = useRef<HTMLDivElement>(null)
|
||||
const sourceTextareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
@ -4264,6 +4269,29 @@ function FileEditor({
|
||||
[draftContent, isSkillManifestFile],
|
||||
)
|
||||
const csvRows = useMemo(() => parseCsvRows(draftContent), [draftContent])
|
||||
const hasPublishedVersion = !!detail?.latest_published_version_id
|
||||
const latestPublishedVersionNumber = detail?.latest_published_version_number
|
||||
const latestPublishedVersionText =
|
||||
typeof latestPublishedVersionNumber === 'number'
|
||||
? t(($) => $['skillManagement.detail.publishedVersion'], {
|
||||
number: latestPublishedVersionNumber,
|
||||
})
|
||||
: null
|
||||
const latestPublishedAt = detail?.latest_published_at
|
||||
const hasUnpublishedChanges =
|
||||
saveStatus === 'dirty' ||
|
||||
saveStatus === 'saving' ||
|
||||
saveStatus === 'error' ||
|
||||
hasSaveConflict ||
|
||||
hasLocalUnpublishedChanges ||
|
||||
!hasPublishedVersion ||
|
||||
(typeof detail?.updated_at === 'number' &&
|
||||
typeof latestPublishedAt === 'number' &&
|
||||
detail.updated_at > latestPublishedAt)
|
||||
const publishStatusText = hasUnpublishedChanges
|
||||
? t(($) => $['skillManagement.detail.draft'])
|
||||
: (latestPublishedVersionText ?? t(($) => $['skillManagement.detail.published']))
|
||||
const publishDisabled = publishing || !hasUnpublishedChanges
|
||||
const fileHash = file?.hash
|
||||
const editorInstanceKey = `${selectedVersionId ?? 'draft'}:${filePath ?? 'empty'}:${readonly ? 'readonly' : 'draft'}`
|
||||
const editorRenderKey = `${editorInstanceKey}:${externalContentRevision}`
|
||||
@ -4596,16 +4624,20 @@ function FileEditor({
|
||||
setReferenceSelectedIndex(Math.max(filteredReferenceFiles.length - 1, 0))
|
||||
}, [filteredReferenceFiles.length, referenceSelectedIndex])
|
||||
|
||||
const updateDraftContent = (nextContent: string) => {
|
||||
draftContentRef.current = nextContent
|
||||
const isConflictContent = nextContent === saveConflictContentRef.current
|
||||
if (!isConflictContent) {
|
||||
saveConflictContentRef.current = null
|
||||
setHasSaveConflict(false)
|
||||
}
|
||||
setDraftContent(nextContent)
|
||||
setSaveStatus(nextContent === lastSavedContentRef.current ? 'saved' : 'dirty')
|
||||
}
|
||||
const updateDraftContent = useCallback(
|
||||
(nextContent: string) => {
|
||||
draftContentRef.current = nextContent
|
||||
const isConflictContent = nextContent === saveConflictContentRef.current
|
||||
if (!isConflictContent) {
|
||||
saveConflictContentRef.current = null
|
||||
setHasSaveConflict(false)
|
||||
}
|
||||
setDraftContent(nextContent)
|
||||
setSaveStatus(nextContent === lastSavedContentRef.current ? 'saved' : 'dirty')
|
||||
if (nextContent !== lastSavedContentRef.current) onLocalUnpublishedChangesChange(true)
|
||||
},
|
||||
[onLocalUnpublishedChangesChange],
|
||||
)
|
||||
|
||||
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextContent = event.target.value
|
||||
@ -4922,8 +4954,12 @@ function FileEditor({
|
||||
setMetadataAdding(false)
|
||||
}
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (publishing) return
|
||||
const handlePublish = useCallback(async () => {
|
||||
if (publishDisabled) return
|
||||
if (saveStatus === 'saving') {
|
||||
pendingPublishAfterSaveRef.current = true
|
||||
return
|
||||
}
|
||||
|
||||
let contentToPublish = draftContentRef.current
|
||||
if (canEdit && isSkillManifestFile && displayNameDraft !== markdownContent.displayName) {
|
||||
@ -4942,7 +4978,25 @@ function FileEditor({
|
||||
}
|
||||
|
||||
onPublish()
|
||||
}
|
||||
}, [
|
||||
canEdit,
|
||||
detail?.reference_count,
|
||||
displayNameDraft,
|
||||
isSkillManifestFile,
|
||||
markdownContent.displayName,
|
||||
onPublish,
|
||||
publishDisabled,
|
||||
saveDraftContent,
|
||||
saveStatus,
|
||||
updateDraftContent,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingPublishAfterSaveRef.current || saveStatus === 'saving') return
|
||||
|
||||
pendingPublishAfterSaveRef.current = false
|
||||
void handlePublish()
|
||||
}, [handlePublish, saveStatus])
|
||||
|
||||
const saveStateText =
|
||||
saveStatus === 'saving'
|
||||
@ -5419,7 +5473,13 @@ function FileEditor({
|
||||
/>
|
||||
<span aria-hidden className="size-1.5 rounded-[2px] bg-text-tertiary" />
|
||||
<span className="min-w-0 flex-1 truncate system-xs-regular text-text-tertiary">
|
||||
{t(($) => $['skillManagement.detail.draft'])}
|
||||
{publishStatusText}
|
||||
{hasUnpublishedChanges && latestPublishedVersionText && (
|
||||
<>
|
||||
<span className="px-1">·</span>
|
||||
{latestPublishedVersionText}
|
||||
</>
|
||||
)}
|
||||
<span className="px-1">·</span>
|
||||
{saveStateText}
|
||||
</span>
|
||||
@ -5434,8 +5494,8 @@ function FileEditor({
|
||||
<Button
|
||||
variant="primary"
|
||||
className="h-8 px-4"
|
||||
loading={publishing || saveStatus === 'saving'}
|
||||
disabled={saveStatus === 'saving'}
|
||||
loading={publishing}
|
||||
disabled={publishDisabled}
|
||||
onClick={handlePublish}
|
||||
>
|
||||
{t(($) => $['skillManagement.detail.publish'])}
|
||||
@ -6441,6 +6501,12 @@ export default function SkillDetailPage() {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null)
|
||||
const [draftDetailOverride, setDraftDetailOverride] = useState<SkillDetailResponse>()
|
||||
const [hasLocalUnpublishedChanges, setHasLocalUnpublishedChanges] = useState(false)
|
||||
const [publishedOverride, setPublishedOverride] = useState<{
|
||||
id: string
|
||||
publishedAt: number
|
||||
versionNumber: number
|
||||
} | null>(null)
|
||||
const fileMutationCoordinator = useMemo<SkillFileMutationCoordinator>(
|
||||
() => ({
|
||||
latestDetail: undefined,
|
||||
@ -6500,7 +6566,21 @@ export default function SkillDetailPage() {
|
||||
consoleQuery.workspaces.current.skills.bySkillId.restore.post.mutationOptions(),
|
||||
)
|
||||
const queriedDetail = detailQuery.data
|
||||
const detail = draftDetailOverride ?? queriedDetail
|
||||
const baseDetail = draftDetailOverride ?? queriedDetail
|
||||
const detail = useMemo<SkillDetailResponse | undefined>(() => {
|
||||
if (!baseDetail || !publishedOverride) return baseDetail
|
||||
|
||||
return {
|
||||
...baseDetail,
|
||||
latest_published_at: publishedOverride.publishedAt,
|
||||
latest_published_version_id: publishedOverride.id,
|
||||
latest_published_version_number: publishedOverride.versionNumber,
|
||||
updated_at:
|
||||
baseDetail.latest_published_version_id === publishedOverride.id
|
||||
? baseDetail.updated_at
|
||||
: Math.max(baseDetail.updated_at, publishedOverride.publishedAt),
|
||||
}
|
||||
}, [baseDetail, publishedOverride])
|
||||
if (
|
||||
detail &&
|
||||
(!fileMutationCoordinator.latestDetail ||
|
||||
@ -6526,6 +6606,8 @@ export default function SkillDetailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
setDraftDetailOverride(undefined)
|
||||
setHasLocalUnpublishedChanges(false)
|
||||
setPublishedOverride(null)
|
||||
}, [skillId])
|
||||
|
||||
useEffect(() => {
|
||||
@ -6569,8 +6651,14 @@ export default function SkillDetailPage() {
|
||||
body: {},
|
||||
},
|
||||
{
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (version) => {
|
||||
toast.success(t(($) => $['skillManagement.detail.publishSuccess']))
|
||||
setPublishedOverride({
|
||||
id: version.id,
|
||||
publishedAt: version.created_at,
|
||||
versionNumber: version.version_number,
|
||||
})
|
||||
setHasLocalUnpublishedChanges(false)
|
||||
const detailQueryKey = consoleQuery.workspaces.current.skills.bySkillId.get.key({
|
||||
type: 'query',
|
||||
input: {
|
||||
@ -6579,6 +6667,15 @@ export default function SkillDetailPage() {
|
||||
},
|
||||
},
|
||||
})
|
||||
if (detail) {
|
||||
setSkillDetailCache(queryClient, skillId, {
|
||||
...detail,
|
||||
latest_published_at: version.created_at,
|
||||
latest_published_version_id: version.id,
|
||||
latest_published_version_number: version.version_number,
|
||||
updated_at: Math.max(detail.updated_at, version.created_at),
|
||||
})
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: detailQueryKey })
|
||||
await queryClient.refetchQueries({ queryKey: detailQueryKey, type: 'active' })
|
||||
void queryClient.invalidateQueries({
|
||||
@ -6692,8 +6789,10 @@ export default function SkillDetailPage() {
|
||||
detail={detail}
|
||||
file={selectedFile}
|
||||
fileMutationCoordinator={fileMutationCoordinator}
|
||||
hasLocalUnpublishedChanges={hasLocalUnpublishedChanges}
|
||||
onCloseFile={handleCloseFile}
|
||||
onDraftDetailChange={handleDraftDetailChange}
|
||||
onLocalUnpublishedChangesChange={setHasLocalUnpublishedChanges}
|
||||
onOpenVersions={handleOpenVersions}
|
||||
onPublish={handlePublish}
|
||||
onRestoreVersion={handleRestoreSelectedVersion}
|
||||
|
||||
@ -548,6 +548,7 @@
|
||||
"skillManagement.detail.publishSuccess": "Skill published.",
|
||||
"skillManagement.detail.publishUpdate": "Publish update",
|
||||
"skillManagement.detail.published": "Published",
|
||||
"skillManagement.detail.publishedVersion": "Published #{{number}}",
|
||||
"skillManagement.detail.readonly": "Read only",
|
||||
"skillManagement.detail.referenceFiles.confirm": "Enter Confirm",
|
||||
"skillManagement.detail.referenceFiles.empty": "No files available.",
|
||||
|
||||
@ -445,6 +445,39 @@
|
||||
"skillManagement.detail.addTagDescription": "Create or bind a tag to this Skill. New tags are saved with the Skill metadata.",
|
||||
"skillManagement.detail.addTagSuccess": "Tag added.",
|
||||
"skillManagement.detail.back": "Back to Skills",
|
||||
"skillManagement.detail.builder.applyFailed": "ドラフトの更新を適用できませんでした。",
|
||||
"skillManagement.detail.builder.attach": "ファイルを添付",
|
||||
"skillManagement.detail.builder.attachFailed": "ファイルを添付できませんでした。",
|
||||
"skillManagement.detail.builder.attachUnsupported": "テキストファイルとドキュメントファイルのみ添付できます。",
|
||||
"skillManagement.detail.builder.attachmentOnlyMessage": "添付ファイルの内容を使って、この Skill を改善してください。",
|
||||
"skillManagement.detail.builder.close": "Skill Builder を閉じる",
|
||||
"skillManagement.detail.builder.compatibleModelsOnly": "互換性のあるモデルのみ表示",
|
||||
"skillManagement.detail.builder.editIntro": "この Skill の内容を確認しました。何を調整しますか?",
|
||||
"skillManagement.detail.builder.exampleIssueTriage": "顧客問い合わせの優先度分類",
|
||||
"skillManagement.detail.builder.exampleOnboarding": "新入社員オンボーディングガイド",
|
||||
"skillManagement.detail.builder.exampleSalesFollowUp": "営業リードのフォローアップ戦略",
|
||||
"skillManagement.detail.builder.followUpDisplayName": "表示名を Refund approval にする",
|
||||
"skillManagement.detail.builder.followUpNameIcon": "提案された名前とアイコンを適用",
|
||||
"skillManagement.detail.builder.fromMarketplace": "Marketplace から",
|
||||
"skillManagement.detail.builder.model": "GPT-4o",
|
||||
"skillManagement.detail.builder.modelCredits.all": "すべてのクレジット",
|
||||
"skillManagement.detail.builder.modelCredits.configure": "設定が必要",
|
||||
"skillManagement.detail.builder.modelCredits.exhausted": "クレジットを使い切りました",
|
||||
"skillManagement.detail.builder.modelProviderSettings": "モデルプロバイダー設定",
|
||||
"skillManagement.detail.builder.modelSearch": "モデルを検索...",
|
||||
"skillManagement.detail.builder.modifyPlaceholder": "AI にこの Skill の修正を依頼...",
|
||||
"skillManagement.detail.builder.open": "Skill Builder を開く",
|
||||
"skillManagement.detail.builder.placeholder": "シナリオを説明...",
|
||||
"skillManagement.detail.builder.promptDescription": "説明すると、手順やファイルを含むドラフトがエディターに作成されます。",
|
||||
"skillManagement.detail.builder.promptTitle": "この Skill で何を扱いますか?",
|
||||
"skillManagement.detail.builder.removeAttachment": "{{name}} を削除",
|
||||
"skillManagement.detail.builder.restart": "Builder を再開始",
|
||||
"skillManagement.detail.builder.send": "メッセージを送信",
|
||||
"skillManagement.detail.builder.sendFailed": "Skill Builder が応答できませんでした。",
|
||||
"skillManagement.detail.builder.title": "Skill Builder",
|
||||
"skillManagement.detail.builder.tryExample": "例を試す",
|
||||
"skillManagement.detail.builder.voice": "音声入力",
|
||||
"skillManagement.detail.builder.voiceUnavailable": "音声入力はまだ利用できません。",
|
||||
"skillManagement.detail.cancelAddMetadata": "Cancel adding metadata",
|
||||
"skillManagement.detail.closeFileTab": "Close {{name}}",
|
||||
"skillManagement.detail.collapseSidebar": "サイドバーを折りたたむ",
|
||||
|
||||
@ -548,6 +548,7 @@
|
||||
"skillManagement.detail.publishSuccess": "Skill 已发布。",
|
||||
"skillManagement.detail.publishUpdate": "发布更新",
|
||||
"skillManagement.detail.published": "Published",
|
||||
"skillManagement.detail.publishedVersion": "已发布 #{{number}}",
|
||||
"skillManagement.detail.readonly": "只读",
|
||||
"skillManagement.detail.referenceFiles.confirm": "Enter 确认",
|
||||
"skillManagement.detail.referenceFiles.empty": "暂无可引用的文件。",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user