feat(article): support regenerating failed articles
This commit is contained in:
@@ -4,6 +4,7 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from '@ant-design/icons-vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
@@ -22,6 +23,11 @@ withDefaults(
|
|||||||
showCopy?: boolean
|
showCopy?: boolean
|
||||||
canCopy?: boolean
|
canCopy?: boolean
|
||||||
copyDisabledTitle?: string
|
copyDisabledTitle?: string
|
||||||
|
showRegenerate?: boolean
|
||||||
|
canRegenerate?: boolean
|
||||||
|
regenerateDisabledTitle?: string
|
||||||
|
regenerateConfirmTitle?: string
|
||||||
|
regenerateLoading?: boolean
|
||||||
showDelete?: boolean
|
showDelete?: boolean
|
||||||
canDelete?: boolean
|
canDelete?: boolean
|
||||||
deleteDisabledTitle?: string
|
deleteDisabledTitle?: string
|
||||||
@@ -41,6 +47,11 @@ withDefaults(
|
|||||||
showCopy: false,
|
showCopy: false,
|
||||||
canCopy: true,
|
canCopy: true,
|
||||||
copyDisabledTitle: '',
|
copyDisabledTitle: '',
|
||||||
|
showRegenerate: false,
|
||||||
|
canRegenerate: true,
|
||||||
|
regenerateDisabledTitle: '',
|
||||||
|
regenerateConfirmTitle: '',
|
||||||
|
regenerateLoading: false,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
canDelete: true,
|
canDelete: true,
|
||||||
deleteDisabledTitle: '',
|
deleteDisabledTitle: '',
|
||||||
@@ -54,6 +65,7 @@ const emit = defineEmits<{
|
|||||||
edit: []
|
edit: []
|
||||||
preview: []
|
preview: []
|
||||||
copy: []
|
copy: []
|
||||||
|
regenerate: []
|
||||||
delete: []
|
delete: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -135,6 +147,43 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
|
|||||||
</span>
|
</span>
|
||||||
</a-tooltip>
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-tooltip
|
||||||
|
v-if="showRegenerate && !regenerateConfirmTitle"
|
||||||
|
:title="resolveTitle(canRegenerate, t('common.regenerate'), regenerateDisabledTitle)"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-regenerate"
|
||||||
|
:disabled="!canRegenerate"
|
||||||
|
:loading="regenerateLoading"
|
||||||
|
@click="emit('regenerate')"
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</a-button>
|
||||||
|
</span>
|
||||||
|
</a-tooltip>
|
||||||
|
|
||||||
|
<a-popconfirm
|
||||||
|
v-else-if="showRegenerate"
|
||||||
|
:title="regenerateConfirmTitle"
|
||||||
|
:disabled="!canRegenerate"
|
||||||
|
@confirm="emit('regenerate')"
|
||||||
|
>
|
||||||
|
<a-button
|
||||||
|
type="text"
|
||||||
|
shape="circle"
|
||||||
|
size="small"
|
||||||
|
class="action-btn action-regenerate"
|
||||||
|
:disabled="!canRegenerate"
|
||||||
|
:loading="regenerateLoading"
|
||||||
|
>
|
||||||
|
<ReloadOutlined />
|
||||||
|
</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
|
||||||
<a-tooltip
|
<a-tooltip
|
||||||
v-if="showDelete && !deleteConfirmTitle"
|
v-if="showDelete && !deleteConfirmTitle"
|
||||||
:title="resolveTitle(canDelete, t('common.delete'), deleteDisabledTitle)"
|
:title="resolveTitle(canDelete, t('common.delete'), deleteDisabledTitle)"
|
||||||
@@ -195,6 +244,11 @@ function resolveTitle(enabled: boolean, enabledTitle: string, disabledTitle?: st
|
|||||||
background: #e6f4ff;
|
background: #e6f4ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-regenerate.ant-btn:hover:not(:disabled) {
|
||||||
|
color: #fa8c16;
|
||||||
|
background: #fff7e6;
|
||||||
|
}
|
||||||
|
|
||||||
.action-delete.ant-btn:hover:not(:disabled) {
|
.action-delete.ant-btn:hover:not(:disabled) {
|
||||||
color: #ff4d4f;
|
color: #ff4d4f;
|
||||||
background: #fff2f0;
|
background: #fff2f0;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
|
|||||||
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
||||||
import { articlesApi, promptRulesApi } from '@/lib/api'
|
import { articlesApi, promptRulesApi } from '@/lib/api'
|
||||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||||
|
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||||
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
|
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
|
||||||
import { formatError } from '@/lib/errors'
|
import { formatError } from '@/lib/errors'
|
||||||
import { formatPublishPlatformList } from '@/lib/publish-platforms'
|
import { formatPublishPlatformList } from '@/lib/publish-platforms'
|
||||||
@@ -110,6 +111,8 @@ const deleteMutation = useMutation({
|
|||||||
onError: (error) => message.error(formatError(error)),
|
onError: (error) => message.error(formatError(error)),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
|
||||||
|
|
||||||
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
const columns = computed<TableColumnsType<ArticleListItem>>(() => [
|
||||||
{ title: t('custom.article.titleColumn'), dataIndex: 'title', key: 'title', width: 340 },
|
{ title: t('custom.article.titleColumn'), dataIndex: 'title', key: 'title', width: 340 },
|
||||||
{
|
{
|
||||||
@@ -353,13 +356,16 @@ onBeforeUnmount(() => {
|
|||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<ArticleActionGroup
|
<ArticleActionGroup
|
||||||
v-bind="resolveArticleActionState(record.generate_status)"
|
v-bind="resolveArticleActionState(record)"
|
||||||
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
:delete-loading="deleteMutation.isPending.value"
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
:regenerate-confirm-title="t('common.regenerateConfirm')"
|
||||||
|
:regenerate-loading="regeneratingArticleId === record.id"
|
||||||
@publish="openPublish(record)"
|
@publish="openPublish(record)"
|
||||||
@edit="openEditor(record)"
|
@edit="openEditor(record)"
|
||||||
@preview="openDetail(record)"
|
@preview="openDetail(record)"
|
||||||
@copy="copyArticle(record.id)"
|
@copy="copyArticle(record.id)"
|
||||||
|
@regenerate="handleRegenerate(record)"
|
||||||
@delete="deleteMutation.mutate(record.id)"
|
@delete="deleteMutation.mutate(record.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const enUS = {
|
|||||||
upgrade: 'Upgrade',
|
upgrade: 'Upgrade',
|
||||||
copy: 'Copy',
|
copy: 'Copy',
|
||||||
copySuccess: 'Content copied',
|
copySuccess: 'Content copied',
|
||||||
|
regenerate: 'Regenerate',
|
||||||
|
regenerateConfirm: 'Regenerate this article?',
|
||||||
|
regenerateSuccess: 'Regeneration queued',
|
||||||
|
regenerateError: 'Regeneration failed, please try again',
|
||||||
yes: 'Yes',
|
yes: 'Yes',
|
||||||
no: 'No',
|
no: 'No',
|
||||||
imageUploadProgress: {
|
imageUploadProgress: {
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const zhCN = {
|
|||||||
upgrade: "去升级",
|
upgrade: "去升级",
|
||||||
copy: "复制",
|
copy: "复制",
|
||||||
copySuccess: "内容已复制",
|
copySuccess: "内容已复制",
|
||||||
|
regenerate: "重新生成",
|
||||||
|
regenerateConfirm: "确认重新生成这篇文章吗?",
|
||||||
|
regenerateSuccess: "重新生成已提交",
|
||||||
|
regenerateError: "重新生成失败,请稍后重试",
|
||||||
yes: "是",
|
yes: "是",
|
||||||
no: "否",
|
no: "否",
|
||||||
imageUploadProgress: {
|
imageUploadProgress: {
|
||||||
|
|||||||
@@ -13,16 +13,16 @@ import type {
|
|||||||
BrandRequest,
|
BrandRequest,
|
||||||
Competitor,
|
Competitor,
|
||||||
CompetitorRequest,
|
CompetitorRequest,
|
||||||
CreateArticleRequest,
|
|
||||||
CreateKolPackageRequest,
|
|
||||||
CreateKolPromptRequest,
|
|
||||||
CreateAckRequest,
|
|
||||||
ComplianceAckRecord,
|
ComplianceAckRecord,
|
||||||
ComplianceCheckArticleRequest,
|
ComplianceCheckArticleRequest,
|
||||||
ComplianceCheckPlainRequest,
|
ComplianceCheckPlainRequest,
|
||||||
ComplianceCheckResult,
|
ComplianceCheckResult,
|
||||||
ComplianceManualReview,
|
ComplianceManualReview,
|
||||||
ComplianceRuntimeStatus,
|
ComplianceRuntimeStatus,
|
||||||
|
CreateAckRequest,
|
||||||
|
CreateArticleRequest,
|
||||||
|
CreateKolPackageRequest,
|
||||||
|
CreateKolPromptRequest,
|
||||||
CreatePluginSessionRequest,
|
CreatePluginSessionRequest,
|
||||||
CreatePluginSessionResponse,
|
CreatePluginSessionResponse,
|
||||||
CreatePublishBatchRequest,
|
CreatePublishBatchRequest,
|
||||||
@@ -92,6 +92,7 @@ import type {
|
|||||||
QuotaSummary,
|
QuotaSummary,
|
||||||
RecentArticle,
|
RecentArticle,
|
||||||
RefreshResponse,
|
RefreshResponse,
|
||||||
|
RegenerateArticleResponse,
|
||||||
RegisterPluginInstallationRequest,
|
RegisterPluginInstallationRequest,
|
||||||
RegisterPluginInstallationResponse,
|
RegisterPluginInstallationResponse,
|
||||||
ScheduleTask,
|
ScheduleTask,
|
||||||
@@ -626,6 +627,12 @@ export const articlesApi = {
|
|||||||
payload,
|
payload,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
regenerate(id: number) {
|
||||||
|
return apiClient.post<RegenerateArticleResponse, null>(
|
||||||
|
`/api/tenant/articles/${id}/regenerate`,
|
||||||
|
null,
|
||||||
|
)
|
||||||
|
},
|
||||||
detail(id: number) {
|
detail(id: number) {
|
||||||
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`)
|
return apiClient.get<ArticleDetail>(`/api/tenant/articles/${id}`)
|
||||||
},
|
},
|
||||||
@@ -1115,7 +1122,9 @@ export const complianceApi = {
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
latestArticleCheck(articleId: number) {
|
latestArticleCheck(articleId: number) {
|
||||||
return apiClient.get<ComplianceCheckResult>(`/api/tenant/articles/${articleId}/compliance/latest`)
|
return apiClient.get<ComplianceCheckResult>(
|
||||||
|
`/api/tenant/articles/${articleId}/compliance/latest`,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
createAck(recordId: number, payload: CreateAckRequest) {
|
createAck(recordId: number, payload: CreateAckRequest) {
|
||||||
return apiClient.post<ComplianceAckRecord, CreateAckRequest>(
|
return apiClient.post<ComplianceAckRecord, CreateAckRequest>(
|
||||||
|
|||||||
@@ -6,9 +6,26 @@ export interface ArticleActionState {
|
|||||||
showPreview: boolean
|
showPreview: boolean
|
||||||
showCopy: boolean
|
showCopy: boolean
|
||||||
showDelete: boolean
|
showDelete: boolean
|
||||||
|
showRegenerate: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveArticleActionState(generateStatus?: string | null): ArticleActionState {
|
export interface ArticleActionSource {
|
||||||
|
generate_status?: string | null
|
||||||
|
source_type?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const regeneratableSourceTypes = new Set(['template', 'custom_generation', 'imitation', 'kol'])
|
||||||
|
|
||||||
|
export function resolveArticleActionState(
|
||||||
|
articleOrGenerateStatus?: ArticleActionSource | string | null,
|
||||||
|
): ArticleActionState {
|
||||||
|
const generateStatus =
|
||||||
|
typeof articleOrGenerateStatus === 'string'
|
||||||
|
? articleOrGenerateStatus
|
||||||
|
: articleOrGenerateStatus?.generate_status
|
||||||
|
const sourceType =
|
||||||
|
typeof articleOrGenerateStatus === 'string' ? undefined : articleOrGenerateStatus?.source_type
|
||||||
|
|
||||||
if (generateStatus === 'completed') {
|
if (generateStatus === 'completed') {
|
||||||
return {
|
return {
|
||||||
showPublish: true,
|
showPublish: true,
|
||||||
@@ -16,6 +33,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
showPreview: true,
|
showPreview: true,
|
||||||
showCopy: true,
|
showCopy: true,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
|
showRegenerate: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +44,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
showPreview: false,
|
showPreview: false,
|
||||||
showCopy: false,
|
showCopy: false,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
|
showRegenerate: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +55,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
showPreview: false,
|
showPreview: false,
|
||||||
showCopy: false,
|
showCopy: false,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
|
showRegenerate: sourceType ? regeneratableSourceTypes.has(sourceType) : false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +65,7 @@ export function resolveArticleActionState(generateStatus?: string | null): Artic
|
|||||||
showPreview: true,
|
showPreview: true,
|
||||||
showCopy: false,
|
showCopy: false,
|
||||||
showDelete: true,
|
showDelete: true,
|
||||||
|
showRegenerate: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ArticleListItem, RecentArticle } from '@geo/shared-types'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { message } from 'ant-design-vue'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import { articlesApi } from '@/lib/api'
|
||||||
|
import { formatError } from '@/lib/errors'
|
||||||
|
|
||||||
|
type RegeneratableArticle = Pick<ArticleListItem | RecentArticle, 'id'>
|
||||||
|
|
||||||
|
export function useArticleRegenerateAction() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const regeneratingArticleId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const regenerateMutation = useMutation({
|
||||||
|
mutationFn: (article: RegeneratableArticle) => articlesApi.regenerate(article.id),
|
||||||
|
onSuccess: async () => {
|
||||||
|
message.success(t('common.regenerateSuccess'))
|
||||||
|
regeneratingArticleId.value = null
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['articles'] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['workspace'] }),
|
||||||
|
])
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
regeneratingArticleId.value = null
|
||||||
|
message.error(formatError(error) || t('common.regenerateError'))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleRegenerate(article: RegeneratableArticle): Promise<void> {
|
||||||
|
regeneratingArticleId.value = article.id
|
||||||
|
await regenerateMutation.mutateAsync(article)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
regeneratingArticleId,
|
||||||
|
regenerateMutation,
|
||||||
|
handleRegenerate,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
|
|||||||
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
||||||
import { articlesApi } from '@/lib/api'
|
import { articlesApi } from '@/lib/api'
|
||||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||||
|
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||||
import { getPublishStatusMeta } from '@/lib/display'
|
import { getPublishStatusMeta } from '@/lib/display'
|
||||||
import { formatError } from '@/lib/errors'
|
import { formatError } from '@/lib/errors'
|
||||||
|
|
||||||
@@ -101,6 +102,8 @@ const createMutation = useMutation({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
|
||||||
|
|
||||||
const publishStatusOptions = computed(() => [
|
const publishStatusOptions = computed(() => [
|
||||||
{ label: getPublishStatusMeta('unpublished').label, value: 'unpublished' },
|
{ label: getPublishStatusMeta('unpublished').label, value: 'unpublished' },
|
||||||
{ label: getPublishStatusMeta('publishing').label, value: 'publishing' },
|
{ label: getPublishStatusMeta('publishing').label, value: 'publishing' },
|
||||||
@@ -309,13 +312,16 @@ async function copyArticle(articleId: number): Promise<void> {
|
|||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<ArticleActionGroup
|
<ArticleActionGroup
|
||||||
v-bind="resolveArticleActionState(record.generate_status)"
|
v-bind="resolveArticleActionState(record)"
|
||||||
:delete-confirm-title="t('freeCreate.list.deleteConfirm')"
|
:delete-confirm-title="t('freeCreate.list.deleteConfirm')"
|
||||||
:delete-loading="deleteMutation.isPending.value"
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
:regenerate-confirm-title="t('common.regenerateConfirm')"
|
||||||
|
:regenerate-loading="regeneratingArticleId === record.id"
|
||||||
@publish="openPublish(record)"
|
@publish="openPublish(record)"
|
||||||
@edit="openEditor(record)"
|
@edit="openEditor(record)"
|
||||||
@preview="openPreview(record)"
|
@preview="openPreview(record)"
|
||||||
@copy="copyArticle(record.id)"
|
@copy="copyArticle(record.id)"
|
||||||
|
@regenerate="handleRegenerate(record)"
|
||||||
@delete="handleDelete(record.id)"
|
@delete="handleDelete(record.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
|
|||||||
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
||||||
import { articlesApi } from '@/lib/api'
|
import { articlesApi } from '@/lib/api'
|
||||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||||
|
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||||
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
|
import { getGenerateStatusMeta, getPublishStatusMeta } from '@/lib/display'
|
||||||
import { formatError } from '@/lib/errors'
|
import { formatError } from '@/lib/errors'
|
||||||
|
|
||||||
@@ -99,6 +100,8 @@ const deleteMutation = useMutation({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
|
||||||
|
|
||||||
const generateStatusOptions = computed(() => [
|
const generateStatusOptions = computed(() => [
|
||||||
{ label: getGenerateStatusMeta('generating').label, value: 'generating' },
|
{ label: getGenerateStatusMeta('generating').label, value: 'generating' },
|
||||||
{ label: getGenerateStatusMeta('completed').label, value: 'completed' },
|
{ label: getGenerateStatusMeta('completed').label, value: 'completed' },
|
||||||
@@ -435,13 +438,16 @@ async function copyArticle(articleId: number): Promise<void> {
|
|||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<ArticleActionGroup
|
<ArticleActionGroup
|
||||||
v-bind="resolveArticleActionState(record.generate_status)"
|
v-bind="resolveArticleActionState(record)"
|
||||||
:delete-confirm-title="t('imitation.list.deleteConfirm')"
|
:delete-confirm-title="t('imitation.list.deleteConfirm')"
|
||||||
:delete-loading="deleteMutation.isPending.value"
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
:regenerate-confirm-title="t('common.regenerateConfirm')"
|
||||||
|
:regenerate-loading="regeneratingArticleId === record.id"
|
||||||
@publish="openPublish(record)"
|
@publish="openPublish(record)"
|
||||||
@edit="openEditor(record)"
|
@edit="openEditor(record)"
|
||||||
@preview="openPreview(record)"
|
@preview="openPreview(record)"
|
||||||
@copy="copyArticle(record.id)"
|
@copy="copyArticle(record.id)"
|
||||||
|
@regenerate="handleRegenerate(record)"
|
||||||
@delete="handleDelete(record.id)"
|
@delete="handleDelete(record.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
|
|||||||
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
||||||
import { articlesApi, templatesApi, workspaceApi } from '@/lib/api'
|
import { articlesApi, templatesApi, workspaceApi } from '@/lib/api'
|
||||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||||
|
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||||
import {
|
import {
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
getGenerateStatusMeta,
|
getGenerateStatusMeta,
|
||||||
@@ -216,6 +217,8 @@ const deleteMutation = useMutation({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
|
||||||
|
|
||||||
const templateFilterOptions = computed(() => {
|
const templateFilterOptions = computed(() => {
|
||||||
return [
|
return [
|
||||||
{ label: t('templates.sections.generalTemplates'), value: 'source:template' },
|
{ label: t('templates.sections.generalTemplates'), value: 'source:template' },
|
||||||
@@ -599,13 +602,16 @@ function refreshRecords(): void {
|
|||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<ArticleActionGroup
|
<ArticleActionGroup
|
||||||
v-bind="resolveArticleActionState(record.generate_status)"
|
v-bind="resolveArticleActionState(record)"
|
||||||
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
:delete-loading="deleteMutation.isPending.value"
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
:regenerate-confirm-title="t('common.regenerateConfirm')"
|
||||||
|
:regenerate-loading="regeneratingArticleId === record.id"
|
||||||
@publish="openPublish(record)"
|
@publish="openPublish(record)"
|
||||||
@edit="openEditor(record)"
|
@edit="openEditor(record)"
|
||||||
@preview="openPreview(record)"
|
@preview="openPreview(record)"
|
||||||
@copy="copyArticle(record.id)"
|
@copy="copyArticle(record.id)"
|
||||||
|
@regenerate="handleRegenerate(record)"
|
||||||
@delete="handleDelete(record.id)"
|
@delete="handleDelete(record.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import ArticleSourceMeta from '@/components/ArticleSourceMeta.vue'
|
|||||||
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
import PublishArticleModal from '@/components/PublishArticleModal.vue'
|
||||||
import { articlesApi, tenantAccountsApi, workspaceApi } from '@/lib/api'
|
import { articlesApi, tenantAccountsApi, workspaceApi } from '@/lib/api'
|
||||||
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
import { buildArticleClipboardContent, resolveArticleActionState } from '@/lib/article-list-actions'
|
||||||
|
import { useArticleRegenerateAction } from '@/lib/article-regenerate-action'
|
||||||
import { formatDateTime, getTemplateMeta } from '@/lib/display'
|
import { formatDateTime, getTemplateMeta } from '@/lib/display'
|
||||||
import { formatError } from '@/lib/errors'
|
import { formatError } from '@/lib/errors'
|
||||||
import { isMediaPublishAccount } from '@/lib/publish-platforms'
|
import { isMediaPublishAccount } from '@/lib/publish-platforms'
|
||||||
@@ -157,6 +158,8 @@ const deleteMutation = useMutation({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { regeneratingArticleId, handleRegenerate } = useArticleRegenerateAction()
|
||||||
|
|
||||||
// Helper for the gradient classes based on index/type
|
// Helper for the gradient classes based on index/type
|
||||||
function getTemplateCardClass(idx: number): string {
|
function getTemplateCardClass(idx: number): string {
|
||||||
const classes = ['card-yellow', 'card-blue-light', 'card-blue-deep', 'card-grey']
|
const classes = ['card-yellow', 'card-blue-light', 'card-blue-deep', 'card-grey']
|
||||||
@@ -451,13 +454,16 @@ function stopRecentArticlesPolling(): void {
|
|||||||
|
|
||||||
<template v-else-if="column.key === 'actions'">
|
<template v-else-if="column.key === 'actions'">
|
||||||
<ArticleActionGroup
|
<ArticleActionGroup
|
||||||
v-bind="resolveArticleActionState(record.generate_status)"
|
v-bind="resolveArticleActionState(record)"
|
||||||
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
:delete-confirm-title="t('templates.list.deleteConfirm')"
|
||||||
:delete-loading="deleteMutation.isPending.value"
|
:delete-loading="deleteMutation.isPending.value"
|
||||||
|
:regenerate-confirm-title="t('common.regenerateConfirm')"
|
||||||
|
:regenerate-loading="regeneratingArticleId === record.id"
|
||||||
@publish="openPublish(record.id)"
|
@publish="openPublish(record.id)"
|
||||||
@edit="openEditor(record)"
|
@edit="openEditor(record)"
|
||||||
@preview="openArticle(record.id)"
|
@preview="openArticle(record.id)"
|
||||||
@copy="copyArticle(record.id)"
|
@copy="copyArticle(record.id)"
|
||||||
|
@regenerate="handleRegenerate(record)"
|
||||||
@delete="handleDelete(record.id)"
|
@delete="handleDelete(record.id)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -753,6 +753,11 @@ export interface GenerateImitationResponse {
|
|||||||
task_id: number
|
task_id: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegenerateArticleResponse {
|
||||||
|
article_id: number
|
||||||
|
task_id: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface UpdateArticleRequest {
|
export interface UpdateArticleRequest {
|
||||||
title: string
|
title: string
|
||||||
markdown_content: string
|
markdown_content: string
|
||||||
|
|||||||
@@ -408,7 +408,6 @@ func (s *ArticleImitationService) executeGeneration(ctx context.Context, job art
|
|||||||
|
|
||||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||||
ArticleID: job.ArticleID,
|
ArticleID: job.ArticleID,
|
||||||
VersionNo: 1,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
HTMLContent: "",
|
HTMLContent: "",
|
||||||
MarkdownContent: content,
|
MarkdownContent: content,
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegenerateArticleResponse struct {
|
||||||
|
ArticleID int64 `json:"article_id"`
|
||||||
|
TaskID int64 `json:"task_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type articleRegenerationRecord struct {
|
||||||
|
ArticleID int64
|
||||||
|
SourceType string
|
||||||
|
TemplateID *int64
|
||||||
|
KolPromptID *int64
|
||||||
|
PromptRuleID *int64
|
||||||
|
GenerateStatus string
|
||||||
|
WizardStateJSON []byte
|
||||||
|
SourceTaskID int64
|
||||||
|
TaskType string
|
||||||
|
RequestHash *string
|
||||||
|
InputParamsJSON []byte
|
||||||
|
InputParams map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) Regenerate(ctx context.Context, articleID int64) (*RegenerateArticleResponse, error) {
|
||||||
|
actor := auth.MustActor(ctx)
|
||||||
|
if s == nil || s.llm == nil || s.rabbitMQ == nil {
|
||||||
|
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is not configured")
|
||||||
|
}
|
||||||
|
if err := s.llm.Validate(); err != nil {
|
||||||
|
return nil, response.ErrServiceUnavailable(50311, "llm_unavailable", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaRepo := repository.NewQuotaRepository(s.pool)
|
||||||
|
balance, err := quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
|
||||||
|
if err != nil || balance < 1 {
|
||||||
|
return nil, response.ErrForbidden(40301, "quota_insufficient", "generation quota insufficient, please upgrade your plan")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin regeneration tx: %w", err)
|
||||||
|
}
|
||||||
|
defer rollbackGenerationTx(tx)
|
||||||
|
|
||||||
|
record, err := loadArticleForRegeneration(ctx, tx, actor.TenantID, articleID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := validateArticleRegeneration(record); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quotaTx := repository.NewQuotaRepository(tx)
|
||||||
|
auditTx := repository.NewAuditRepository(tx)
|
||||||
|
|
||||||
|
newBalance := balance - 1
|
||||||
|
reason := "generation_reserve"
|
||||||
|
referenceType := "generation_task"
|
||||||
|
if _, err := quotaTx.InsertQuotaLedger(ctx, repository.QuotaLedgerInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
QuotaType: "article_generation",
|
||||||
|
Delta: -1,
|
||||||
|
BalanceAfter: newBalance,
|
||||||
|
Reason: &reason,
|
||||||
|
ReferenceType: &referenceType,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("reserve regeneration quota: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reservationID, err := quotaTx.CreateQuotaReservation(ctx, repository.QuotaReservationInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
QuotaType: "article_generation",
|
||||||
|
ResourceType: "article",
|
||||||
|
ResourceID: nil,
|
||||||
|
ReservedAmount: 1,
|
||||||
|
ExpireAt: time.Now().Add(time.Hour),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create regeneration reservation: %w", err)
|
||||||
|
}
|
||||||
|
if err := quotaTx.UpdateQuotaReservationResource(ctx, reservationID, actor.TenantID, articleID); err != nil {
|
||||||
|
return nil, fmt.Errorf("update regeneration reservation resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := regenerationInitialTitle(record)
|
||||||
|
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, title, "generating", record.WizardStateJSON); err != nil {
|
||||||
|
return nil, fmt.Errorf("mark article regenerating: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHash := record.RequestHash
|
||||||
|
if requestHash == nil {
|
||||||
|
if hash := strings.TrimSpace(extractString(record.InputParams, "rendered_prompt_hash")); hash != "" {
|
||||||
|
requestHash = &hash
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
taskID, err := auditTx.CreateGenerationTask(ctx, repository.GenerationTaskInput{
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ArticleID: &articleID,
|
||||||
|
QuotaReservationID: &reservationID,
|
||||||
|
TaskType: record.TaskType,
|
||||||
|
RequestHash: requestHash,
|
||||||
|
InputParamsJSON: record.InputParamsJSON,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create regeneration task: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if record.TaskType == kolGenerationTaskType {
|
||||||
|
if _, err := auditTx.CreateTaskRecord(ctx, repository.TaskRecordInput{
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
TaskType: kolGenerationTaskRecordType,
|
||||||
|
ResourceType: kolGenerationTaskRecordScope,
|
||||||
|
ResourceID: taskID,
|
||||||
|
Status: "queued",
|
||||||
|
}); err != nil {
|
||||||
|
return nil, fmt.Errorf("create kol regeneration task record: %w", err)
|
||||||
|
}
|
||||||
|
if err := createKolUsageForRegeneration(ctx, tx, actor.TenantID, taskID, record.InputParams); err != nil {
|
||||||
|
return nil, fmt.Errorf("create kol regeneration usage: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit regeneration task: %w", err)
|
||||||
|
}
|
||||||
|
invalidateArticleCaches(ctx, s.cache, actor.TenantID, &articleID)
|
||||||
|
|
||||||
|
task := ClaimedGenerationTask{
|
||||||
|
ID: taskID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
ArticleID: articleID,
|
||||||
|
QuotaReservationID: reservationID,
|
||||||
|
TaskType: record.TaskType,
|
||||||
|
InputParams: cloneInputParams(record.InputParams),
|
||||||
|
}
|
||||||
|
if err := publishArticleGenerationTask(ctx, s.rabbitMQ, articleGenerationTaskEnvelope{
|
||||||
|
TaskID: taskID,
|
||||||
|
TaskType: record.TaskType,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
ArticleID: articleID,
|
||||||
|
}); err != nil {
|
||||||
|
RecordGenerationTaskEvent(GenerationTaskEventQueueFailure, GenerationTaskLogContext{
|
||||||
|
TaskID: taskID,
|
||||||
|
TenantID: actor.TenantID,
|
||||||
|
OperatorID: actor.UserID,
|
||||||
|
ArticleID: articleID,
|
||||||
|
ReservationID: reservationID,
|
||||||
|
TaskType: record.TaskType,
|
||||||
|
Stage: "queue_enqueue",
|
||||||
|
StatusBefore: "queued",
|
||||||
|
StatusAfter: "failed",
|
||||||
|
Result: GenerationTaskResultFailure,
|
||||||
|
})
|
||||||
|
s.failRegenerationQueue(context.Background(), task, title, err)
|
||||||
|
return nil, response.ErrServiceUnavailable(50310, "generation_queue_unavailable", "generation queue is busy, please retry")
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeCfg := s.runtimeConfig()
|
||||||
|
if runtimeCfg.StreamEnabled && s.streams != nil {
|
||||||
|
s.streams.Start(articleID, taskID, title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &RegenerateArticleResponse{ArticleID: articleID, TaskID: taskID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadArticleForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, articleID int64) (*articleRegenerationRecord, error) {
|
||||||
|
record := &articleRegenerationRecord{}
|
||||||
|
var templateID, kolPromptID, promptRuleID sql.NullInt64
|
||||||
|
err := tx.QueryRow(ctx, `
|
||||||
|
SELECT id, source_type, template_id, kol_prompt_id, prompt_rule_id, generate_status, wizard_state_json
|
||||||
|
FROM articles
|
||||||
|
WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL
|
||||||
|
FOR UPDATE
|
||||||
|
`, articleID, tenantID).Scan(
|
||||||
|
&record.ArticleID,
|
||||||
|
&record.SourceType,
|
||||||
|
&templateID,
|
||||||
|
&kolPromptID,
|
||||||
|
&promptRuleID,
|
||||||
|
&record.GenerateStatus,
|
||||||
|
&record.WizardStateJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50010, "query_failed", "failed to fetch article")
|
||||||
|
}
|
||||||
|
record.TemplateID = nullableSQLInt64(templateID)
|
||||||
|
record.KolPromptID = nullableSQLInt64(kolPromptID)
|
||||||
|
record.PromptRuleID = nullableSQLInt64(promptRuleID)
|
||||||
|
|
||||||
|
var requestHash sql.NullString
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
SELECT id, task_type, request_hash, input_params_json
|
||||||
|
FROM generation_tasks
|
||||||
|
WHERE article_id = $1
|
||||||
|
AND tenant_id = $2
|
||||||
|
AND status = 'failed'
|
||||||
|
ORDER BY (COALESCE(input_params_json, '{}'::jsonb) = '{}'::jsonb), created_at DESC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, articleID, tenantID).Scan(
|
||||||
|
&record.SourceTaskID,
|
||||||
|
&record.TaskType,
|
||||||
|
&requestHash,
|
||||||
|
&record.InputParamsJSON,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, response.ErrConflict(40914, "generation_task_missing", "failed generation task is missing")
|
||||||
|
}
|
||||||
|
return nil, response.ErrInternal(50010, "query_failed", "failed to fetch generation task")
|
||||||
|
}
|
||||||
|
if requestHash.Valid {
|
||||||
|
record.RequestHash = &requestHash.String
|
||||||
|
}
|
||||||
|
if len(record.InputParamsJSON) == 0 {
|
||||||
|
record.InputParamsJSON = []byte("{}")
|
||||||
|
}
|
||||||
|
params := map[string]interface{}{}
|
||||||
|
if err := json.Unmarshal(record.InputParamsJSON, ¶ms); err != nil {
|
||||||
|
return nil, response.ErrConflict(40915, "generation_params_invalid", "failed generation params are invalid")
|
||||||
|
}
|
||||||
|
record.InputParams = params
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateArticleRegeneration(record *articleRegenerationRecord) error {
|
||||||
|
if record == nil {
|
||||||
|
return response.ErrNotFound(40411, "article_not_found", "article not found")
|
||||||
|
}
|
||||||
|
if record.GenerateStatus != "failed" {
|
||||||
|
return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch record.SourceType {
|
||||||
|
case "template":
|
||||||
|
if record.TaskType == "template" && record.TemplateID != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case "custom_generation":
|
||||||
|
if record.TaskType == "custom_generation" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case articleImitationSourceType:
|
||||||
|
if record.TaskType == articleImitationTaskType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
case kolGenerationTaskType:
|
||||||
|
if record.TaskType == kolGenerationTaskType {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||||
|
}
|
||||||
|
|
||||||
|
func regenerationInitialTitle(record *articleRegenerationRecord) string {
|
||||||
|
if record == nil {
|
||||||
|
return "Generated Article"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch record.TaskType {
|
||||||
|
case "template":
|
||||||
|
return resolveArticleTitle(record.InputParams, "")
|
||||||
|
case "custom_generation":
|
||||||
|
if title := strings.TrimSpace(extractString(record.InputParams, "task_name")); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return promptRuleGeneratingTitlePlaceholder
|
||||||
|
case articleImitationTaskType:
|
||||||
|
return buildImitationInitialTitle(extractString(record.InputParams, "source_title"))
|
||||||
|
case kolGenerationTaskType:
|
||||||
|
if title := strings.TrimSpace(extractString(record.InputParams, "prompt_name")); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if title := strings.TrimSpace(extractString(record.InputParams, "title")); title != "" {
|
||||||
|
return title
|
||||||
|
}
|
||||||
|
return "Generated Article"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) failRegenerationQueue(ctx context.Context, task ClaimedGenerationTask, title string, taskErr error) {
|
||||||
|
if task.TaskType == kolGenerationTaskType {
|
||||||
|
_ = HandleKolGenerationTaskFailure(ctx, s.pool, s.cache, task, "queue_enqueue", taskErr)
|
||||||
|
} else {
|
||||||
|
_ = HandleClaimedGenerationTaskFailure(ctx, s.pool, nil, nil, task, taskErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
runtimeCfg := s.runtimeConfig()
|
||||||
|
if runtimeCfg.StreamEnabled && s.streams != nil {
|
||||||
|
s.streams.Fail(task.ArticleID, task.ID, title, formatGenerationFailure("queue_enqueue", taskErr))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createKolUsageForRegeneration(ctx context.Context, tx pgx.Tx, tenantID, taskID int64, params map[string]interface{}) error {
|
||||||
|
packageID := int64(extractInt(params, "package_id"))
|
||||||
|
promptID := int64(extractInt(params, "prompt_id"))
|
||||||
|
if packageID <= 0 || promptID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
promptRevisionNo := extractInt(params, "prompt_revision_no")
|
||||||
|
if promptRevisionNo <= 0 {
|
||||||
|
promptRevisionNo = 1
|
||||||
|
}
|
||||||
|
subscriptionID := optionalInt64FromParams(params, "subscription_id")
|
||||||
|
subscriptionPromptID := optionalInt64FromParams(params, "subscription_prompt_id")
|
||||||
|
variablesSnapshot, err := marshalOptionalJSONField(params, "variables")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schemaSnapshot, err := marshalOptionalJSONField(params, "schema_snapshot")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
renderedPromptHash := strings.TrimSpace(extractString(params, "rendered_prompt_hash"))
|
||||||
|
var renderedPromptHashPtr *string
|
||||||
|
if renderedPromptHash != "" {
|
||||||
|
renderedPromptHashPtr = &renderedPromptHash
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = repository.NewKolUsageRepository(tx).Create(ctx, repository.CreateKolUsageLogInput{
|
||||||
|
TenantID: tenantID,
|
||||||
|
SubscriptionID: subscriptionID,
|
||||||
|
SubscriptionPromptID: subscriptionPromptID,
|
||||||
|
PackageID: packageID,
|
||||||
|
PromptID: promptID,
|
||||||
|
PromptRevisionNo: promptRevisionNo,
|
||||||
|
GenerationTaskID: &taskID,
|
||||||
|
VariablesSnapshot: variablesSnapshot,
|
||||||
|
SchemaSnapshot: schemaSnapshot,
|
||||||
|
RenderedPromptHash: renderedPromptHashPtr,
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalOptionalJSONField(params map[string]interface{}, key string) ([]byte, error) {
|
||||||
|
if len(params) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
value, ok := params[key]
|
||||||
|
if !ok || value == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalInt64FromParams(params map[string]interface{}, key string) *int64 {
|
||||||
|
value := int64(extractInt(params, key))
|
||||||
|
if value <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func nullableSQLInt64(value sql.NullInt64) *int64 {
|
||||||
|
if !value.Valid {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := value.Int64
|
||||||
|
return &result
|
||||||
|
}
|
||||||
@@ -27,19 +27,28 @@ import (
|
|||||||
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
"github.com/geo-platform/tenant-api/internal/shared/auditlog"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
"github.com/geo-platform/tenant-api/internal/shared/auth"
|
||||||
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/config"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
"github.com/geo-platform/tenant-api/internal/shared/contentstats"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/llm"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/messaging/rabbitmq"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
"github.com/geo-platform/tenant-api/internal/shared/middleware"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||||
|
"github.com/geo-platform/tenant-api/internal/shared/stream"
|
||||||
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
"github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ArticleService struct {
|
type ArticleService struct {
|
||||||
pool *pgxpool.Pool
|
pool *pgxpool.Pool
|
||||||
auditLogs *auditlog.AsyncWriter
|
auditLogs *auditlog.AsyncWriter
|
||||||
objectStorage objectstorage.Client
|
objectStorage objectstorage.Client
|
||||||
cache sharedcache.Cache
|
llm llm.Client
|
||||||
cacheGroup singleflight.Group
|
rabbitMQ *rabbitmq.Client
|
||||||
|
streams *stream.GenerationHub
|
||||||
|
cfg generationRuntimeConfig
|
||||||
|
configProvider runtimeConfigProvider
|
||||||
|
cache sharedcache.Cache
|
||||||
|
cacheGroup singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
func NewArticleService(pool *pgxpool.Pool, auditLogs *auditlog.AsyncWriter, objectStorage objectstorage.Client) *ArticleService {
|
||||||
@@ -51,6 +60,37 @@ func (s *ArticleService) WithCache(c sharedcache.Cache) *ArticleService {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) WithGenerationRuntime(
|
||||||
|
llmClient llm.Client,
|
||||||
|
rabbitMQClient *rabbitmq.Client,
|
||||||
|
streams *stream.GenerationHub,
|
||||||
|
cfg config.GenerationConfig,
|
||||||
|
llmMaxOutputTokens int64,
|
||||||
|
) *ArticleService {
|
||||||
|
s.llm = llmClient
|
||||||
|
s.rabbitMQ = rabbitMQClient
|
||||||
|
s.streams = streams
|
||||||
|
s.cfg = generationRuntimeFromConfig(cfg, config.LLMConfig{MaxOutputTokens: llmMaxOutputTokens})
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) WithConfigProvider(provider runtimeConfigProvider) *ArticleService {
|
||||||
|
s.configProvider = provider
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ArticleService) runtimeConfig() generationRuntimeConfig {
|
||||||
|
if s != nil && s.configProvider != nil {
|
||||||
|
if cfg := s.configProvider.Current(); cfg != nil {
|
||||||
|
return generationRuntimeFromConfig(cfg.Generation, cfg.LLM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
return generationRuntimeFromConfig(config.GenerationConfig{}, config.LLMConfig{})
|
||||||
|
}
|
||||||
|
return s.cfg
|
||||||
|
}
|
||||||
|
|
||||||
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
const maxArticleImageSizeBytes = 10 * 1024 * 1024
|
||||||
|
|
||||||
var supportedArticleImageExtensions = map[string]string{
|
var supportedArticleImageExtensions = map[string]string{
|
||||||
|
|||||||
@@ -569,7 +569,6 @@ func (s *PromptRuleGenerationService) executeGeneration(ctx context.Context, job
|
|||||||
|
|
||||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||||
ArticleID: job.ArticleID,
|
ArticleID: job.ArticleID,
|
||||||
VersionNo: 1,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
HTMLContent: "",
|
HTMLContent: "",
|
||||||
MarkdownContent: content,
|
MarkdownContent: content,
|
||||||
|
|||||||
@@ -349,11 +349,11 @@ func (s *TemplateService) Generate(ctx context.Context, templateID int64, req Ge
|
|||||||
AND template_id = $3
|
AND template_id = $3
|
||||||
AND source_type = 'template'
|
AND source_type = 'template'
|
||||||
AND deleted_at IS NULL
|
AND deleted_at IS NULL
|
||||||
AND generate_status = 'draft'
|
AND generate_status IN ('draft', 'failed')
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
`, *req.ArticleID, actor.TenantID, templateID).Scan(&articleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, response.ErrConflict(40913, "draft_not_editable", "draft is not editable")
|
return nil, response.ErrConflict(40913, "article_not_regeneratable", "article cannot be regenerated in its current state")
|
||||||
}
|
}
|
||||||
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
|
if err := updateGeneratedArticleState(ctx, tx, articleID, actor.TenantID, initialTitle, "generating", wizardStateJSON); err != nil {
|
||||||
return nil, fmt.Errorf("mark draft generating: %w", err)
|
return nil, fmt.Errorf("mark draft generating: %w", err)
|
||||||
@@ -536,7 +536,6 @@ func (s *TemplateService) executeGeneration(ctx context.Context, job generationJ
|
|||||||
|
|
||||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||||
ArticleID: job.ArticleID,
|
ArticleID: job.ArticleID,
|
||||||
VersionNo: 1,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
HTMLContent: "",
|
HTMLContent: "",
|
||||||
MarkdownContent: content,
|
MarkdownContent: content,
|
||||||
|
|||||||
@@ -51,7 +51,25 @@ func (r *articleRepository) CreateArticle(ctx context.Context, input CreateArtic
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func NextArticleVersionNo(ctx context.Context, db generated.DBTX, articleID int64) (int, error) {
|
||||||
|
var nextVersionNo int
|
||||||
|
err := db.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(MAX(version_no), 0) + 1
|
||||||
|
FROM article_versions
|
||||||
|
WHERE article_id = $1
|
||||||
|
`, articleID).Scan(&nextVersionNo)
|
||||||
|
return nextVersionNo, err
|
||||||
|
}
|
||||||
|
|
||||||
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
|
func (r *articleRepository) CreateArticleVersion(ctx context.Context, input CreateArticleVersionInput) (int64, error) {
|
||||||
|
if input.VersionNo <= 0 {
|
||||||
|
nextVersionNo, err := NextArticleVersionNo(ctx, r.db, input.ArticleID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
input.VersionNo = nextVersionNo
|
||||||
|
}
|
||||||
|
|
||||||
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
|
previous, err := loadLatestArticleVersionContent(ctx, r.db, input.ArticleID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -43,7 +43,16 @@ func NewArticleHandler(a *bootstrap.App) *ArticleHandler {
|
|||||||
).WithConfigProvider(a.ConfigStore)
|
).WithConfigProvider(a.ConfigStore)
|
||||||
|
|
||||||
return &ArticleHandler{
|
return &ArticleHandler{
|
||||||
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).WithCache(a.Cache),
|
svc: app.NewArticleService(a.DB, a.AuditLogs, a.ObjectStorage).
|
||||||
|
WithCache(a.Cache).
|
||||||
|
WithGenerationRuntime(
|
||||||
|
a.LLM,
|
||||||
|
a.RabbitMQ,
|
||||||
|
a.GenerationStreams,
|
||||||
|
a.Config().Generation,
|
||||||
|
a.Config().LLM.MaxOutputTokens,
|
||||||
|
).
|
||||||
|
WithConfigProvider(a.ConfigStore),
|
||||||
selectionOptimize: app.NewArticleSelectionOptimizeService(
|
selectionOptimize: app.NewArticleSelectionOptimizeService(
|
||||||
a.DB,
|
a.DB,
|
||||||
a.LLM,
|
a.LLM,
|
||||||
@@ -188,6 +197,22 @@ func (h *ArticleHandler) GenerateImitation(c *gin.Context) {
|
|||||||
response.SuccessWithStatus(c, http.StatusCreated, data)
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ArticleHandler) Regenerate(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, response.ErrBadRequest(40001, "invalid_id", "article id must be a number"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.svc.Regenerate(c.Request.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
response.Error(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.SuccessWithStatus(c, http.StatusCreated, data)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ArticleHandler) Create(c *gin.Context) {
|
func (h *ArticleHandler) Create(c *gin.Context) {
|
||||||
var req app.CreateArticleRequest
|
var req app.CreateArticleRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ func RegisterRoutes(app *bootstrap.App) {
|
|||||||
articles.POST("", artHandler.Create)
|
articles.POST("", artHandler.Create)
|
||||||
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
articles.POST("/generate-from-rule", artHandler.GenerateFromRule)
|
||||||
articles.POST("/imitations/generate", artHandler.GenerateImitation)
|
articles.POST("/imitations/generate", artHandler.GenerateImitation)
|
||||||
|
articles.POST("/:id/regenerate", artHandler.Regenerate)
|
||||||
articles.POST("/:id/images", artHandler.UploadImage)
|
articles.POST("/:id/images", artHandler.UploadImage)
|
||||||
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
articles.POST("/:id/selection-optimize/stream", artHandler.OptimizeSelectionStream)
|
||||||
articles.GET("/:id", artHandler.Detail)
|
articles.GET("/:id", artHandler.Detail)
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ func (w *KolGenerationWorker) Process(ctx context.Context, task tenantapp.Claime
|
|||||||
|
|
||||||
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
versionID, err := articleRepo.CreateArticleVersion(ctx, repository.CreateArticleVersionInput{
|
||||||
ArticleID: task.ArticleID,
|
ArticleID: task.ArticleID,
|
||||||
VersionNo: 1,
|
|
||||||
Title: title,
|
Title: title,
|
||||||
HTMLContent: "",
|
HTMLContent: "",
|
||||||
MarkdownContent: content,
|
MarkdownContent: content,
|
||||||
|
|||||||
Reference in New Issue
Block a user