feat: update publish button logic

This commit is contained in:
fatelei 2026-07-30 22:02:05 +08:00
parent ce38c21253
commit 51b668a2a6
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
9 changed files with 227 additions and 29 deletions

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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),

View File

@ -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()

View File

@ -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}

View File

@ -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.",

View File

@ -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": "サイドバーを折りたたむ",

View File

@ -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": "暂无可引用的文件。",