fix: Enhance Kol Variable Rendering and Management

- Updated the regex for placeholder matching to support more flexible key formats.
- Refactored variable storage to allow both ID and key lookups in rendering.
- Improved schema validation to check for duplicate keys and enforce non-empty keys.
- Added new utility functions for variable value lookup and display name generation.
- Enhanced tests to cover new key-based variable scenarios.
- Refactored KolPrompt repository to simplify prompt storage and management, including the removal of obsolete revision handling.
- Introduced new API endpoints for saving, activating, and archiving prompts.
- Added new utility functions for handling Kol placeholders and platform options in the admin web.
- Implemented database migrations to simplify prompt storage structure and ensure data integrity.
This commit is contained in:
2026-04-18 00:47:57 +08:00
parent 614ca4a2ea
commit 3ef0807456
32 changed files with 2414 additions and 3518 deletions
+1
View File
@@ -12,3 +12,4 @@ apps/*/.wxt/
apps/*/chrome-mv*/
apps/*/firefox-mv*/
.claude
.superpowers/*
@@ -52,8 +52,16 @@ import { callCommand, replaceAll } from "@milkdown/kit/utils";
import { Crepe, CrepeFeature } from "@milkdown/crepe";
import "@milkdown/crepe/theme/common/style.css";
import "@milkdown/crepe/theme/frame.css";
import { Milkdown, useEditor, useInstance } from "@milkdown/vue";
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
ref,
shallowRef,
watch,
type ComponentPublicInstance,
} from "vue";
import { useI18n } from "vue-i18n";
import EditorActionMenu from "@/components/editor-common/EditorActionMenu.vue";
@@ -88,8 +96,11 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const crepeRef = ref<Crepe | null>(null);
const crepeRef = shallowRef<Crepe | null>(null);
const syncingExternalMarkdown = ref(false);
const editorRootRef = ref<HTMLElement | null>(null);
const editorRef = shallowRef<MilkdownEditorInstance | null>(null);
const loading = ref(true);
const surfaceRef = ref<HTMLElement | null>(null);
const tablePickerRef = ref<HTMLElement | null>(null);
@@ -197,6 +208,8 @@ type SelectedImageSnapshot = {
align: RichImageAlign;
};
type MilkdownEditorInstance = Awaited<ReturnType<Crepe["create"]>>;
function createDefaultTableContextMenuState(): TableContextMenuState {
return {
open: false,
@@ -248,7 +261,7 @@ function createDefaultAiOptimizePanelState(): AiOptimizePanelState {
};
}
const { loading } = useEditor((root) => {
function createCrepe(root: HTMLElement): Crepe {
const crepe = new Crepe({
root,
defaultValue: props.modelValue || "",
@@ -298,10 +311,13 @@ const { loading } = useEditor((root) => {
crepeRef.value = crepe;
return crepe;
});
}
const [instanceLoading, getEditor] = useInstance();
const editorDisabled = computed(() => props.disabled || instanceLoading.value || loading.value);
function getEditor(): MilkdownEditorInstance | null {
return editorRef.value;
}
const editorDisabled = computed(() => props.disabled || loading.value);
const tablePickerOpen = ref(false);
const imagePickerOpen = ref(false);
const imagePickerAction = ref<ImagePickerAction>("insert");
@@ -528,9 +544,9 @@ watch(
);
watch(
[() => props.modelValue, loading, instanceLoading],
([value, editorLoading, currentInstanceLoading]) => {
if (editorLoading || currentInstanceLoading) {
[() => props.modelValue, loading],
([value, editorLoading]) => {
if (editorLoading) {
return;
}
@@ -539,6 +555,26 @@ watch(
{ immediate: true },
);
async function mountEditor(): Promise<void> {
const root = editorRootRef.value;
if (!root || crepeRef.value) {
return;
}
loading.value = true;
const crepe = createCrepe(root);
try {
editorRef.value = await crepe.create();
} catch (error) {
crepeRef.value = null;
console.error(error);
} finally {
loading.value = false;
}
}
onBeforeUnmount(() => {
stopAiOptimizeGeneration();
if (selectedImageSyncFrame) {
@@ -549,6 +585,8 @@ onBeforeUnmount(() => {
window.removeEventListener("pointerdown", handleWindowPointerDown);
window.removeEventListener("resize", handleWindowResize);
window.removeEventListener("keydown", handleWindowKeydown);
editorRef.value = null;
void crepeRef.value?.destroy().catch(console.error);
crepeRef.value = null;
});
@@ -556,6 +594,7 @@ onMounted(() => {
window.addEventListener("pointerdown", handleWindowPointerDown);
window.addEventListener("resize", handleWindowResize);
window.addEventListener("keydown", handleWindowKeydown);
void mountEditor();
});
function runCommand(command: { key: unknown }, payload?: unknown): void {
@@ -680,6 +719,18 @@ function closeTablePicker(): void {
tablePickerOpen.value = false;
}
function assignTablePickerRef(element: Element | ComponentPublicInstance | null): void {
tablePickerRef.value = element instanceof HTMLElement ? element : null;
}
function assignSurfaceRef(element: Element | ComponentPublicInstance | null): void {
surfaceRef.value = element instanceof HTMLElement ? element : null;
}
function assignEditorRootRef(element: Element | ComponentPublicInstance | null): void {
editorRootRef.value = element instanceof HTMLElement ? element : null;
}
function closeTableContextMenu(): void {
tableContextMenu.value = createDefaultTableContextMenuState();
}
@@ -1830,7 +1881,7 @@ function runTableContextAction(action: TableContextMenuAction): void {
<template #icon><OrderedListOutlined /></template>
</a-button>
<div ref="tablePickerRef" class="article-editor-canvas__table-trigger">
<div :ref="assignTablePickerRef" class="article-editor-canvas__table-trigger">
<a-button
size="small"
type="text"
@@ -1861,7 +1912,7 @@ function runTableContextAction(action: TableContextMenuAction): void {
</div>
<div
ref="surfaceRef"
:ref="assignSurfaceRef"
class="article-editor-canvas__surface"
@contextmenu.capture="handleEditorContextMenu"
@dragstart.capture="handleEditorDragStart"
@@ -1881,7 +1932,7 @@ function runTableContextAction(action: TableContextMenuAction): void {
/>
</div>
<Milkdown />
<div :ref="assignEditorRootRef" data-milkdown-root></div>
<EditorSelectionFrame
v-if="selectedImage.open"
@@ -4,7 +4,7 @@ import { useQuery } from "@tanstack/vue-query";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { imagesApi } from "@/lib/api";
import { formatBytes } from "@/lib/display";
import { formatBytes, formatPercentage } from "@/lib/display";
import type { ImageAssetItem } from "@geo/shared-types";
const props = withDefaults(defineProps<{
@@ -139,7 +139,7 @@ function getSelectPopupContainer(triggerNode: HTMLElement) {
<span>{{ t("images.storageUsage") }}: {{ t("images.storageUsageDetail", {
used: formatBytes(storageUsage.used_bytes),
total: formatBytes(storageUsage.quota_bytes),
percent: storageUsage.used_pct
percent: formatPercentage(storageUsage.used_pct)
}) }}</span>
</div>
<a-progress
@@ -25,8 +25,12 @@ function updateField(key: string, value: any) {
}
const selectOptions = (variable: KolVariableDefinition) => {
return (variable.options || []).map(opt => ({ label: opt, value: opt }));
return (variable.options || []).map((opt) => ({ label: opt, value: opt }));
};
function fieldKey(variable: KolVariableDefinition): string {
return variable.key?.trim() || variable.id;
}
</script>
<template>
@@ -39,44 +43,44 @@ const selectOptions = (variable: KolVariableDefinition) => {
>
<template v-if="variable.type === 'input'">
<a-input
:value="modelValue[variable.key]"
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.inputPlease')"
@update:value="(val: string) => updateField(variable.key, val)"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
/>
</template>
<template v-else-if="variable.type === 'textarea'">
<a-textarea
:value="modelValue[variable.key]"
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.inputPlease')"
:rows="4"
@update:value="(val: string) => updateField(variable.key, val)"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
/>
</template>
<template v-else-if="variable.type === 'select'">
<a-select
:value="modelValue[variable.key]"
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.selectPlease')"
:options="selectOptions(variable)"
@update:value="(val: string) => updateField(variable.key, val)"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
/>
</template>
<template v-else-if="variable.type === 'number'">
<a-input-number
:value="modelValue[variable.key]"
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder"
style="width: 100%"
@update:value="(val: number) => updateField(variable.key, val)"
@update:value="(val: number) => updateField(fieldKey(variable), val)"
/>
</template>
<template v-else-if="variable.type === 'checkbox'">
<a-checkbox-group
:value="modelValue[variable.key] || []"
:value="modelValue[fieldKey(variable)] || []"
:options="selectOptions(variable)"
@update:value="(val: string[]) => updateField(variable.key, val)"
@update:value="(val: string[]) => updateField(fieldKey(variable), val)"
/>
</template>
</a-form-item>
@@ -1,11 +1,16 @@
<script setup lang="ts">
import { ref } from "vue";
import { computed, h, ref } from "vue";
import { useI18n } from "vue-i18n";
import { ExperimentOutlined, ThunderboltOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";
import { newVariableId } from "@/lib/kol-variable-id";
import { Modal } from "ant-design-vue";
import {
buildKolPlaceholderToken,
createKolVariableDefinition,
findKolPlaceholderKeyAtPosition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
content: string;
variables: KolVariableDefinition[];
@@ -17,17 +22,46 @@ const emit = defineEmits<{
(e: "update:variables", value: KolVariableDefinition[]): void;
(e: "ai-generate", description: string): void;
(e: "ai-optimize"): void;
(e: "focus-variable", key: string): void;
}>();
const { t } = useI18n();
const aiDescription = ref("");
const textareaRef = ref<HTMLTextAreaElement | null>(null);
const highlightRef = ref<HTMLDivElement | null>(null);
const editorPlaceholder = "在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量";
const highlightTokenRE = /\{\{\s*([^{}]+?)\s*\}\}/g;
const highlightedContent = computed(() => {
const text = props.content ?? "";
if (!text) {
return `<span class="editor-placeholder">${escapeHtml(editorPlaceholder)}</span>`;
}
let html = "";
let lastIndex = 0;
for (const match of text.matchAll(highlightTokenRE)) {
const matchIndex = match.index ?? 0;
const token = match[0] ?? "";
html += escapeHtml(text.slice(lastIndex, matchIndex));
html += `<span class="token-highlight">${escapeHtml(token)}</span>`;
lastIndex = matchIndex + token.length;
}
html += escapeHtml(text.slice(lastIndex));
return html;
});
function onDrop(event: DragEvent) {
event.preventDefault();
const type = event.dataTransfer?.getData("application/kol-variable-type") as KolVariableType;
if (!type) return;
const labelValue = ref("");
Modal.confirm({
title: t("kol.manage.variable.label"),
content: () => {
@@ -35,34 +69,29 @@ function onDrop(event: DragEvent) {
h("input", {
class: "ant-input",
placeholder: t("common.inputPlease"),
value: labelValue.value,
onInput: (e: Event) => {
(Modal.confirm as any).labelValue = (e.target as HTMLInputElement).value;
labelValue.value = (e.target as HTMLInputElement).value;
},
}),
]);
},
onOk: () => {
const label = (Modal.confirm as any).labelValue || t(`kol.manage.variable.${type}`);
const id = newVariableId();
const key = label;
const newVar: KolVariableDefinition = {
id,
key,
label,
type,
required: true,
};
const key = labelValue.value.trim() || t(`kol.manage.variable.${type}`);
const existingVariable = props.variables.find((variable) => variable.key === key);
emit("update:variables", [...props.variables, newVar]);
insertToken(`{{${key}}}`);
if (!existingVariable) {
const newVar: KolVariableDefinition = createKolVariableDefinition(key, {
type,
});
emit("update:variables", [...props.variables, newVar]);
}
insertToken(buildKolPlaceholderToken(key));
},
});
}
// Helper to use 'h' since we are in script setup
import { h } from "vue";
function insertToken(token: string) {
if (!textareaRef.value) return;
@@ -78,6 +107,7 @@ function insertToken(token: string) {
setTimeout(() => {
el.focus();
el.setSelectionRange(start + token.length, start + token.length);
syncScroll();
}, 0);
}
@@ -85,6 +115,45 @@ function handleAiGenerate() {
if (!aiDescription.value.trim()) return;
emit("ai-generate", aiDescription.value);
}
function handleContentInput(event: Event) {
emit("update:content", (event.target as HTMLTextAreaElement).value);
syncScroll();
}
function handleCaretInteraction() {
if (!textareaRef.value) {
return;
}
requestAnimationFrame(() => {
const textarea = textareaRef.value;
if (!textarea) {
return;
}
const key = findKolPlaceholderKeyAtPosition(props.content, textarea.selectionStart ?? -1);
if (key) {
emit("focus-variable", key);
}
});
}
function syncScroll() {
if (!textareaRef.value || !highlightRef.value) {
return;
}
highlightRef.value.scrollTop = textareaRef.value.scrollTop;
highlightRef.value.scrollLeft = textareaRef.value.scrollLeft;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
</script>
<template>
@@ -126,12 +195,21 @@ function handleAiGenerate() {
<span>AI 正在思考中...</span>
</div>
</div>
<div
ref="highlightRef"
class="prompt-highlight"
aria-hidden="true"
v-html="highlightedContent"
></div>
<textarea
ref="textareaRef"
class="prompt-textarea"
:value="content"
@input="e => emit('update:content', (e.target as HTMLTextAreaElement).value)"
placeholder="在这里输入提示词内容,可以使用 {{变量名}} 引用左侧定义的变量"
:placeholder="editorPlaceholder"
@input="handleContentInput"
@mouseup="handleCaretInteraction"
@keyup="handleCaretInteraction"
@scroll="syncScroll"
></textarea>
</div>
</div>
@@ -166,21 +244,50 @@ function handleAiGenerate() {
flex: 1;
position: relative;
display: flex;
overflow: hidden;
}
.prompt-highlight {
position: absolute;
inset: 0;
overflow: auto;
padding: 24px;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
color: #262626;
pointer-events: none;
user-select: none;
}
.prompt-textarea {
position: relative;
z-index: 1;
flex: 1;
border: none;
resize: none;
padding: 24px;
background: transparent;
font-family: inherit;
font-size: 16px;
line-height: 1.6;
outline: none;
color: #262626;
color: transparent;
caret-color: #262626;
}
.prompt-textarea::placeholder {
color: transparent;
}
.prompt-highlight :deep(.token-highlight) {
color: #eb2f96;
font-weight: 600;
}
.prompt-highlight :deep(.editor-placeholder) {
color: #bfbfbf;
}
@@ -1,22 +1,28 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery, useQueryClient } from "@tanstack/vue-query";
import { message } from "ant-design-vue";
import { SaveOutlined, SendOutlined, EyeOutlined } from "@ant-design/icons-vue";
import { LeftOutlined, SaveOutlined, SendOutlined, EyeOutlined } from "@ant-design/icons-vue";
import KolVariablePanel from "./KolVariablePanel.vue";
import KolVariableConfig from "./KolVariableConfig.vue";
import KolPromptEditArea from "./KolPromptEditArea.vue";
import KolDynamicForm from "./KolDynamicForm.vue";
import { kolManageApi } from "@/lib/api";
import type { KolVariableDefinition, KolSchemaJson } from "@geo/shared-types";
import type { KolVariableDefinition } from "@geo/shared-types";
import { formatError } from "@/lib/errors";
import { buildKolPlatformOptions } from "@/lib/kol-platform-options";
import { syncKolVariablesWithContent } from "@/lib/kol-placeholders";
const props = defineProps<{
promptId: number;
}>();
const emit = defineEmits<{
back: [];
}>();
const { t } = useI18n();
const queryClient = useQueryClient();
@@ -25,16 +31,32 @@ const variables = ref<KolVariableDefinition[]>([]);
const previewOpen = ref(false);
const previewValues = ref<Record<string, any>>({});
const aiTaskId = ref<string | null>(null);
const variableConfigRef = ref<{ focusVariable: (key: string) => void } | null>(null);
const metadataForm = reactive({
name: "",
platform_hint: "通用",
});
const { data: promptDetail, isPending } = useQuery({
const { data: promptDetail } = useQuery({
queryKey: computed(() => ["kol", "prompt", props.promptId]),
queryFn: () => kolManageApi.getPrompt(props.promptId),
});
const platformOptions = computed(() => {
const options = buildKolPlatformOptions(t("kol.manage.platformHintDefault"));
const currentValue = metadataForm.platform_hint.trim();
if (currentValue && !options.some((option) => option.value === currentValue)) {
return [{ label: currentValue, value: currentValue }, ...options];
}
return options;
});
watch(
() => promptDetail.value,
(detail) => {
if (detail) {
metadataForm.name = detail.name;
metadataForm.platform_hint = detail.platform_hint || "通用";
content.value = detail.latest_prompt_content ?? "";
variables.value = detail.latest_schema_json?.variables ?? [];
}
@@ -42,27 +64,24 @@ watch(
{ immediate: true }
);
const saveDraftMutation = useMutation({
mutationFn: (data: { content: string; variables: KolVariableDefinition[] }) =>
kolManageApi.saveDraft(props.promptId, {
content: data.content,
schema: { variables: data.variables },
card_config: {},
}),
const publishMutation = useMutation({
mutationFn: (payload: ReturnType<typeof buildPromptPayload>) =>
kolManageApi.publishPrompt(props.promptId, payload),
onSuccess: () => {
message.success(t("common.save") + "成功");
queryClient.invalidateQueries({ queryKey: ["kol", "prompt", props.promptId] });
message.success(t("kol.manage.editor.publish") + "成功");
invalidatePromptQueries();
},
onError: (error) => {
message.error(formatError(error));
},
});
const publishMutation = useMutation({
mutationFn: () => kolManageApi.publishPrompt(props.promptId),
const saveMutation = useMutation({
mutationFn: (payload: ReturnType<typeof buildPromptPayload>) =>
kolManageApi.savePrompt(props.promptId, payload),
onSuccess: () => {
message.success(t("kol.manage.editor.publish") + "成功");
queryClient.invalidateQueries({ queryKey: ["kol", "prompt", props.promptId] });
message.success(t("common.save") + "成功");
invalidatePromptQueries();
},
onError: (error) => {
message.error(formatError(error));
@@ -84,7 +103,7 @@ watch(
(task) => {
if (task?.status === "completed" && task.result) {
content.value = task.result.content;
variables.value = task.result.schema.variables;
variables.value = syncKolVariablesWithContent(task.result.content, task.result.schema.variables);
aiTaskId.value = null;
message.success("AI 处理完成");
} else if (task?.status === "failed") {
@@ -109,10 +128,11 @@ async function handleAiGenerate(description: string) {
async function handleAiOptimize() {
try {
const syncedVariables = syncVariablesFromContent();
const res = await kolManageApi.submitAssist({
mode: "optimize",
current_content: content.value,
schema: { variables: variables.value },
schema: { variables: syncedVariables },
prompt_id: props.promptId,
});
aiTaskId.value = res.task_id;
@@ -121,34 +141,126 @@ async function handleAiOptimize() {
}
}
function buildPromptPayload(nextVariables: KolVariableDefinition[] = variables.value) {
return {
name: metadataForm.name.trim(),
platform_hint: metadataForm.platform_hint,
sort_order: promptDetail.value?.sort_order,
content: content.value,
schema: { variables: nextVariables },
card_config: {},
};
}
function syncVariablesFromContent(): KolVariableDefinition[] {
const syncedVariables = syncKolVariablesWithContent(content.value, variables.value);
variables.value = syncedVariables;
return syncedVariables;
}
function buildPreviewValues(nextVariables: KolVariableDefinition[]): Record<string, any> {
const nextValues: Record<string, any> = {};
nextVariables.forEach((variable) => {
const fieldKey = variable.key?.trim() || variable.id;
nextValues[fieldKey] = variable.type === "checkbox" ? [] : undefined;
});
return nextValues;
}
function invalidatePromptQueries() {
void queryClient.invalidateQueries({ queryKey: ["kol", "prompt", props.promptId] });
void queryClient.invalidateQueries({ queryKey: ["kol", "packages"] });
void queryClient.invalidateQueries({
queryKey: ["kol", "packages", promptDetail.value?.package_id, "prompts"],
});
}
function validateBeforeSubmit(): boolean {
if (!metadataForm.name.trim()) {
message.warning(t("common.inputPlease") + t("common.title"));
return false;
}
if (!content.value.trim()) {
message.warning("请输入提示词内容");
return false;
}
return true;
}
function handleSave() {
saveDraftMutation.mutate({ content: content.value, variables: variables.value });
const syncedVariables = syncVariablesFromContent();
if (!validateBeforeSubmit()) {
return;
}
saveMutation.mutate(buildPromptPayload(syncedVariables));
}
function handlePublish() {
publishMutation.mutate();
const syncedVariables = syncVariablesFromContent();
if (!validateBeforeSubmit()) {
return;
}
publishMutation.mutate(buildPromptPayload(syncedVariables));
}
function handlePreview() {
previewValues.value = {};
const syncedVariables = syncVariablesFromContent();
previewValues.value = buildPreviewValues(syncedVariables);
previewOpen.value = true;
}
async function handleFocusVariable(key: string) {
syncVariablesFromContent();
await nextTick();
variableConfigRef.value?.focusVariable(key);
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement ?? triggerNode;
}
function statusTagColor(status?: string): string {
if (status === "active") return "green";
if (status === "archived") return "orange";
return "default";
}
function statusLabel(status?: string): string {
if (status === "active") return t("kol.manage.status.active");
if (status === "archived") return t("kol.manage.status.archived");
return t("kol.manage.status.draft");
}
</script>
<template>
<div class="kol-prompt-editor">
<div class="editor-topbar">
<button class="back-btn" type="button" @click="emit('back')">
<LeftOutlined />
<span>{{ t("common.back") }}</span>
</button>
</div>
<div class="editor-header">
<div class="header-title">
<span class="prompt-name">{{ promptDetail?.name || 'Loading...' }}</span>
<div class="title-row">
<span class="prompt-name">{{ metadataForm.name || promptDetail?.name || t("common.loading") }}</span>
<a-tag v-if="promptDetail" :color="statusTagColor(promptDetail.status)">
{{ statusLabel(promptDetail.status) }}
</a-tag>
</div>
<span class="prompt-subtitle">{{ t("kol.manage.editor.basicInfo") }}</span>
</div>
<div class="header-actions">
<a-button @click="handlePreview">
<template #icon><EyeOutlined /></template>
{{ t('kol.manage.editor.preview') }}
</a-button>
<a-button :loading="saveDraftMutation.isPending.value" @click="handleSave">
<a-button :loading="saveMutation.isPending.value" @click="handleSave">
<template #icon><SaveOutlined /></template>
{{ t('kol.manage.editor.saveDraft') }}
{{ t('common.save') }}
</a-button>
<a-button type="primary" :loading="publishMutation.isPending.value" @click="handlePublish">
<template #icon><SendOutlined /></template>
@@ -157,6 +269,28 @@ function handlePreview() {
</div>
</div>
<div class="editor-meta-card">
<div class="editor-section-title">{{ t("kol.manage.editor.basicInfo") }}</div>
<a-form layout="vertical" class="editor-meta-form">
<a-form-item :label="t('common.title')" required>
<a-input
v-model:value="metadataForm.name"
:placeholder="t('common.inputPlease') + t('common.title')"
/>
</a-form-item>
<a-form-item :label="t('kol.manage.platformHint')">
<a-select
v-model:value="metadataForm.platform_hint"
:options="platformOptions"
option-filter-prop="label"
show-search
:get-popup-container="getSelectPopupContainer"
/>
</a-form-item>
</a-form>
</div>
<div class="editor-body">
<KolVariablePanel />
<KolPromptEditArea
@@ -165,8 +299,12 @@ function handlePreview() {
:ai-task-id="aiTaskId"
@ai-generate="handleAiGenerate"
@ai-optimize="handleAiOptimize"
@focus-variable="handleFocusVariable"
/>
<KolVariableConfig
ref="variableConfigRef"
v-model:variables="variables"
/>
<KolVariableConfig v-model:variables="variables" />
</div>
<a-modal
@@ -190,30 +328,133 @@ function handlePreview() {
flex-direction: column;
}
.editor-topbar {
padding: 12px 16px;
}
.back-btn {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 0;
border: 0;
background: transparent;
color: #111827;
font-size: 16px;
font-weight: 700;
cursor: pointer;
}
.back-btn :deep(.anticon) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.82);
border: 1px solid rgba(144, 157, 181, 0.24);
}
.editor-header {
height: 56px;
padding: 0 16px;
border-bottom: 1px solid #f0f0f0;
margin: 0 16px;
padding: 16px 20px;
border: 1px solid #f0f0f0;
border-radius: 16px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
background: #fff;
}
.header-title {
min-width: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.title-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.prompt-name {
font-size: 16px;
font-weight: 500;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.prompt-subtitle {
font-size: 13px;
color: #667085;
}
.header-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: flex-end;
}
.editor-meta-card {
margin: 12px 16px 0;
padding: 16px 20px 4px;
border: 1px solid #f0f0f0;
border-radius: 16px;
background: #fff;
}
.editor-section-title {
margin: 0 0 16px;
font-size: 14px;
font-weight: 600;
color: #111827;
}
.editor-meta-form {
display: grid;
grid-template-columns: minmax(240px, 1.4fr) minmax(200px, 1fr);
gap: 16px;
align-items: start;
}
.editor-body {
flex: 1;
display: flex;
overflow: hidden;
margin: 12px 16px 16px;
border: 1px solid #f0f0f0;
border-radius: 16px;
background: #f0f0f0;
}
@media (max-width: 1200px) {
.editor-meta-form {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 900px) {
.editor-header {
flex-direction: column;
align-items: flex-start;
}
.header-actions {
width: 100%;
justify-content: flex-start;
}
.editor-meta-form {
grid-template-columns: 1fr;
}
.editor-body {
flex-direction: column;
}
}
</style>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import { computed, reactive, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { CreateKolPromptRequest } from "@geo/shared-types";
import { buildKolPlatformOptions } from "@/lib/kol-platform-options";
const props = defineProps<{
open: boolean;
@@ -20,12 +21,7 @@ const formState = reactive<CreateKolPromptRequest>({
platform_hint: "通用",
});
const platformOptions = [
{ label: "通用", value: "通用" },
{ label: "豆包", value: "豆包" },
{ label: "DeepSeek", value: "DeepSeek" },
{ label: "ChatGPT", value: "ChatGPT" },
];
const platformOptions = computed(() => buildKolPlatformOptions(t("kol.manage.platformHintDefault")));
watch(
() => props.open,
@@ -46,6 +42,10 @@ function handleOk() {
function handleCancel() {
emit("update:open", false);
}
function getSelectPopupContainer(triggerNode: HTMLElement) {
return triggerNode.parentElement ?? triggerNode;
}
</script>
<template>
@@ -60,10 +60,13 @@ function handleCancel() {
<a-input v-model:value="formState.name" :placeholder="t('common.inputPlease')" />
</a-form-item>
<a-form-item label="Platform Hint">
<a-form-item :label="t('kol.manage.platformHint')">
<a-select
v-model:value="formState.platform_hint"
:options="platformOptions"
option-filter-prop="label"
show-search
:get-popup-container="getSelectPopupContainer"
@change="(val: string) => formState.platform_hint = val"
/>
</a-form-item>
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { CloseOutlined } from "@ant-design/icons-vue";
import type { KolVariableDefinition } from "@geo/shared-types";
import { Tag } from "ant-design-vue";
import type { ComponentPublicInstance } from "vue";
import { nextTick, ref } from "vue";
import { CloseOutlined, DownOutlined } from "@ant-design/icons-vue";
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";
import { useI18n } from "vue-i18n";
const props = defineProps<{
@@ -13,17 +14,22 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const panelRef = ref<HTMLDivElement | null>(null);
const activeVariableKey = ref<string>("");
let activeHighlightTimer: number | null = null;
const cardRefs = new Map<string, HTMLElement>();
const componentTypes: Array<{ type: KolVariableType }> = [
{ type: "input" },
{ type: "textarea" },
{ type: "select" },
{ type: "number" },
{ type: "checkbox" },
];
function updateVariable(index: number, updates: Partial<KolVariableDefinition>) {
const newVariables = [...props.variables];
const oldVar = newVariables[index];
// If label is updated and matches key (default behavior), update key too
if (updates.label !== undefined && oldVar.label === oldVar.key) {
updates.key = updates.label;
}
newVariables[index] = { ...oldVar, ...updates };
newVariables[index] = { ...newVariables[index], ...updates };
emit("update:variables", newVariables);
}
@@ -34,19 +40,103 @@ function removeVariable(index: number) {
}
function handleOptionsChange(index: number, value: string) {
const options = value.split(",").map(s => s.trim()).filter(Boolean);
const options = value
.split(",")
.map((item) => item.trim())
.filter(Boolean);
updateVariable(index, { options });
}
function handleTypeChange(index: number, type: KolVariableType) {
updateVariable(index, { type });
}
function setCardRef(key: string, el: Element | ComponentPublicInstance | null) {
const element =
el instanceof HTMLElement
? el
: el && "$el" in el && el.$el instanceof HTMLElement
? el.$el
: null;
if (element) {
cardRefs.set(key, element);
return;
}
cardRefs.delete(key);
}
async function focusVariable(key: string) {
activeVariableKey.value = key;
if (activeHighlightTimer !== null) {
window.clearTimeout(activeHighlightTimer);
}
activeHighlightTimer = window.setTimeout(() => {
if (activeVariableKey.value === key) {
activeVariableKey.value = "";
}
activeHighlightTimer = null;
}, 1800);
await nextTick();
const card = cardRefs.get(key);
const panel = panelRef.value;
if (!card || !panel) {
return;
}
const targetTop = Math.max(card.offsetTop - 16, 0);
panel.scrollTo({
top: targetTop,
behavior: "smooth",
});
requestAnimationFrame(() => {
const input = card.querySelector("input");
if (input instanceof HTMLInputElement) {
input.focus();
input.select();
}
});
}
defineExpose({
focusVariable,
});
</script>
<template>
<div class="kol-variable-config">
<div ref="panelRef" class="kol-variable-config">
<div v-if="variables.length === 0" class="empty-state">
{{ t('common.noData') }}
</div>
<div v-for="(variable, index) in variables" :key="variable.id" class="variable-card">
<div
v-for="(variable, index) in variables"
:key="variable.id"
:ref="(el) => setCardRef(variable.key || variable.id, el)"
class="variable-card"
:class="{ 'variable-card--active': activeVariableKey === (variable.key || variable.id) }"
>
<div class="card-header">
<Tag color="blue">{{ t(`kol.manage.variable.${variable.type}`) }}</Tag>
<a-dropdown :trigger="['click']">
<button type="button" class="type-trigger">
<span>{{ t(`kol.manage.variable.${variable.type}`) }}</span>
<DownOutlined />
</button>
<template #overlay>
<a-menu>
<a-menu-item
v-for="item in componentTypes"
:key="item.type"
@click="handleTypeChange(index, item.type)"
>
{{ t(`kol.manage.variable.${item.type}`) }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
<div class="header-actions">
<a-button type="text" size="small" @click="removeVariable(index)">
<template #icon><CloseOutlined /></template>
@@ -92,7 +182,7 @@ function handleOptionsChange(index: number, value: string) {
</div>
<div class="token-display">
<code>{{ `{{${variable.key}}}` }}</code>
<code>&#123;&#123;{{ variable.key || variable.id }}&#125;&#125;</code>
</div>
</div>
</div>
@@ -121,6 +211,13 @@ function handleOptionsChange(index: number, value: string) {
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
transition: border-color 0.25s ease, box-shadow 0.25s ease, background 0.25s ease;
}
.variable-card--active {
border-color: #ff85c0;
background: #fff7fb;
box-shadow: 0 0 0 3px rgba(235, 47, 150, 0.12);
}
.card-header {
@@ -131,6 +228,26 @@ function handleOptionsChange(index: number, value: string) {
border-bottom: 1px solid #f0f0f0;
}
.type-trigger {
appearance: none;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border: 1px solid #91caff;
border-radius: 999px;
background: #f0f7ff;
color: #1677ff;
font-size: 12px;
line-height: 20px;
cursor: pointer;
}
.type-trigger:hover {
border-color: #69b1ff;
background: #e6f4ff;
}
.card-body {
padding: 12px;
display: flex;
+11
View File
@@ -190,7 +190,18 @@ const enUS = {
publishPackage: "Publish",
archivePackage: "Archive",
createPrompt: "Create prompt",
activatePrompt: "Go live",
archivePrompt: "Take offline",
platformHint: "Platform",
platformHintDefault: "General",
status: {
draft: "Draft",
active: "Live",
archived: "Offline",
},
editor: {
basicInfo: "Basic info",
saveMeta: "Save basic info",
saveDraft: "Save draft",
publish: "Publish",
preview: "Preview form",
+13 -2
View File
@@ -168,7 +168,7 @@ const zhCN = {
},
empty: "暂无已上架的订阅包",
subscribers: "{count} 个订阅租户",
prompts: "{count} 个 Prompt",
prompts: "{count} 个提示词",
},
package: {
subscribe: "申请订阅",
@@ -189,8 +189,19 @@ const zhCN = {
editPackage: "编辑包",
publishPackage: "发布",
archivePackage: "归档",
createPrompt: "新增 Prompt",
createPrompt: "新增提示词",
activatePrompt: "上线",
archivePrompt: "下线",
platformHint: "平台",
platformHintDefault: "通用",
status: {
draft: "草稿",
active: "已上线",
archived: "已下线",
},
editor: {
basicInfo: "基本信息",
saveMeta: "保存基本信息",
saveDraft: "保存草稿",
publish: "发布",
preview: "预览表单",
+21 -12
View File
@@ -33,12 +33,11 @@ import type {
KolAssistTask,
KolPackageSummary,
KolPackageDetail,
PublishKolPromptRequest,
KolSubscription,
KolSubscriptionPromptCard,
KolProfile,
KolPromptDetail,
KolPromptRevision,
KolSaveDraftRequest,
KolSubscriptionPromptSchema,
KolGenerateRequest,
KolGenerateResponse,
@@ -323,15 +322,27 @@ export const kolManageApi = {
deletePrompt(id: number) {
return apiClient.remove<null>(`/api/tenant/kol/manage/prompts/${id}`);
},
saveDraft(promptId: number, body: KolSaveDraftRequest) {
return apiClient.post<KolPromptRevision, KolSaveDraftRequest>(
`/api/tenant/kol/manage/prompts/${promptId}/draft`,
savePrompt(promptId: number, body: PublishKolPromptRequest) {
return apiClient.post<KolPromptDetail, PublishKolPromptRequest>(
`/api/tenant/kol/manage/prompts/${promptId}/save`,
body,
);
},
publishPrompt(promptId: number) {
return apiClient.post<{ ok: boolean }, Record<string, never>>(
publishPrompt(promptId: number, body: PublishKolPromptRequest) {
return apiClient.post<KolPromptDetail, PublishKolPromptRequest>(
`/api/tenant/kol/manage/prompts/${promptId}/publish`,
body,
);
},
activatePrompt(promptId: number) {
return apiClient.put<KolPromptDetail, Record<string, never>>(
`/api/tenant/kol/manage/prompts/${promptId}/activate`,
{},
);
},
archivePrompt(promptId: number) {
return apiClient.put<KolPromptDetail, Record<string, never>>(
`/api/tenant/kol/manage/prompts/${promptId}/archive`,
{},
);
},
@@ -348,7 +359,7 @@ export const kolManageApi = {
export const kolMarketplaceApi = {
list(params: { industry?: string; keyword?: string; limit?: number; offset?: number }) {
return apiClient.get<KolPackageSummary[]>("/api/tenant/kol/marketplace", { params });
return apiClient.get<KolPackageSummary[]>("/api/tenant/kol/marketplace/packages", { params });
},
detail(id: number) {
return apiClient.get<KolPackageDetail>(`/api/tenant/kol/marketplace/packages/${id}`);
@@ -360,12 +371,10 @@ export const kolMarketplaceApi = {
);
},
mySubscriptions() {
return apiClient.get<KolSubscription[]>("/api/tenant/kol/marketplace/my-subscriptions");
return apiClient.get<KolSubscription[]>("/api/tenant/kol/subscriptions");
},
mySubscriptionPrompts() {
return apiClient.get<KolSubscriptionPromptCard[]>(
"/api/tenant/kol/marketplace/my-subscription-prompts",
);
return apiClient.get<KolSubscriptionPromptCard[]>("/api/tenant/kol/subscription-prompts");
},
};
+113
View File
@@ -0,0 +1,113 @@
import type { KolVariableDefinition, KolVariableType } from "@geo/shared-types";
import { newVariableId } from "./kol-variable-id";
const KOL_PLACEHOLDER_RE = /\{\{\s*([^{}]+?)\s*\}\}/g;
function normalizePlaceholderKey(value: string): string {
return value.trim();
}
function normalizeVariableType(type?: KolVariableType): KolVariableType {
switch (type) {
case "textarea":
case "select":
case "number":
case "checkbox":
return type;
case "input":
default:
return "input";
}
}
export function buildKolPlaceholderToken(key: string): string {
return `{{${normalizePlaceholderKey(key)}}}`;
}
export function extractKolPlaceholderKeys(content: string): string[] {
const text = String(content ?? "");
const seen = new Set<string>();
const keys: string[] = [];
for (const match of text.matchAll(KOL_PLACEHOLDER_RE)) {
const key = normalizePlaceholderKey(match[1] ?? "");
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
keys.push(key);
}
return keys;
}
export function findKolPlaceholderKeyAtPosition(content: string, position: number): string | null {
const text = String(content ?? "");
if (!text || position < 0) {
return null;
}
for (const match of text.matchAll(KOL_PLACEHOLDER_RE)) {
const token = match[0] ?? "";
const start = match.index ?? -1;
const end = start + token.length;
if (start < 0) {
continue;
}
if (position >= start && position <= end) {
return normalizePlaceholderKey(match[1] ?? "");
}
}
return null;
}
export function createKolVariableDefinition(
key: string,
overrides: Partial<KolVariableDefinition> = {},
): KolVariableDefinition {
const normalizedKey = normalizePlaceholderKey(key);
return {
id: overrides.id?.trim() || newVariableId(),
key: normalizedKey,
label: overrides.label?.trim() || normalizedKey,
type: normalizeVariableType(overrides.type),
required: overrides.required ?? true,
placeholder: overrides.placeholder,
options: overrides.options,
};
}
export function syncKolVariablesWithContent(
content: string,
variables: KolVariableDefinition[],
): KolVariableDefinition[] {
const keys = extractKolPlaceholderKeys(content);
const byKey = new Map<string, KolVariableDefinition>();
for (const variable of variables) {
const key = normalizePlaceholderKey(variable.key || variable.label || "");
if (!key || byKey.has(key)) {
continue;
}
byKey.set(
key,
createKolVariableDefinition(key, {
...variable,
key,
label: variable.label?.trim() || key,
}),
);
}
return keys.map((key) => {
const existing = byKey.get(key);
if (existing) {
return existing;
}
return createKolVariableDefinition(key);
});
}
@@ -0,0 +1,15 @@
export interface KolPlatformOption {
label: string;
value: string;
}
export function buildKolPlatformOptions(defaultLabel: string): KolPlatformOption[] {
return [
{ label: defaultLabel, value: "通用" },
{ label: "小红书", value: "xiaohongshu" },
{ label: "微信", value: "wechat" },
{ label: "豆包", value: "doubao" },
{ label: "DeepSeek", value: "deepseek" },
{ label: "ChatGPT", value: "chatgpt" },
];
}
+8 -11
View File
@@ -5,7 +5,6 @@ import { message } from "ant-design-vue";
import { computed, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { MilkdownProvider } from "@milkdown/vue";
import ArticleEditorCanvas from "@/components/ArticleEditorCanvas.vue";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
@@ -379,16 +378,14 @@ function serializePlatformSelection(platformIds: string[]): string {
/>
<div class="article-editor-view__layout">
<section class="article-editor-view__main">
<MilkdownProvider>
<ArticleEditorCanvas
:key="String(articleId)"
:article-id="articleId"
v-model:title="title"
v-model="markdown"
:disabled="editorLocked"
:upload-image="uploadEditorImage"
/>
</MilkdownProvider>
<ArticleEditorCanvas
:key="String(articleId)"
:article-id="articleId"
v-model:title="title"
v-model="markdown"
:disabled="editorLocked"
:upload-image="uploadEditorImage"
/>
</section>
<aside class="article-editor-view__rail">
+6 -2
View File
@@ -22,6 +22,10 @@ const schemaQuery = useQuery({
const values = ref<Record<string, any>>({});
function fieldKey(key?: string | null, id?: string): string {
return key?.trim() || id || "";
}
// Initialize values from schema
watch(
() => schemaQuery.data.value,
@@ -29,7 +33,7 @@ watch(
if (schema?.schema_json?.variables) {
const newValues: Record<string, any> = {};
schema.schema_json.variables.forEach((v) => {
newValues[v.key] = v.type === "checkbox" ? [] : undefined;
newValues[fieldKey(v.key, v.id)] = v.type === "checkbox" ? [] : undefined;
});
values.value = newValues;
}
@@ -59,7 +63,7 @@ function handleSubmit() {
const missingLabels: string[] = [];
schema.schema_json.variables.forEach((v) => {
if (v.required && !values.value[v.key]) {
if (v.required && !values.value[fieldKey(v.key, v.id)]) {
missingLabels.push(v.label);
}
});
+215 -123
View File
@@ -6,12 +6,13 @@ import { message, Modal } from "ant-design-vue";
import {
PlusOutlined,
EditOutlined,
PlayCircleOutlined,
PauseCircleOutlined,
CloudUploadOutlined,
InboxOutlined,
DeleteOutlined,
} from "@ant-design/icons-vue";
import PageHero from "@/components/PageHero.vue";
import KolPackageFormModal from "@/components/kol/KolPackageFormModal.vue";
import KolPromptFormModal from "@/components/kol/KolPromptFormModal.vue";
import KolPromptEditor from "@/components/kol/KolPromptEditor.vue";
@@ -26,8 +27,8 @@ const selectedPackageId = ref<number | null>(null);
const packageModalOpen = ref(false);
const editingPackage = ref<KolPackageSummary | null>(null);
const promptModalOpen = ref(false);
const editorDrawerOpen = ref(false);
const editingPromptId = ref<number | null>(null);
const togglingPromptId = ref<number | null>(null);
const { data: packages, isPending: packagesPending } = useQuery({
queryKey: ["kol", "packages"],
@@ -77,11 +78,12 @@ const archivePackageMutation = useMutation({
const deletePackageMutation = useMutation({
mutationFn: (id: number) => kolManageApi.deletePackage(id),
onSuccess: () => {
onSuccess: (_, id) => {
message.success(t("common.delete") + "成功");
queryClient.invalidateQueries({ queryKey: ["kol", "packages"] });
if (selectedPackageId.value === selectedPackageId.value) {
if (selectedPackageId.value === id) {
selectedPackageId.value = null;
editingPromptId.value = null;
}
},
});
@@ -97,6 +99,31 @@ const createPromptMutation = useMutation({
},
onError: (error) => message.error(formatError(error)),
});
const togglePromptStatusMutation = useMutation({
mutationFn: (data: { id: number; nextStatus: "active" | "archived" }) =>
data.nextStatus === "active"
? kolManageApi.activatePrompt(data.id)
: kolManageApi.archivePrompt(data.id),
onMutate: (variables) => {
togglingPromptId.value = variables.id;
},
onSuccess: (_, variables) => {
message.success(
t(variables.nextStatus === "active" ? "kol.manage.activatePrompt" : "kol.manage.archivePrompt") + "成功"
);
void queryClient.invalidateQueries({ queryKey: ["kol", "packages"] });
void queryClient.invalidateQueries({
queryKey: ["kol", "packages", selectedPackageId.value, "prompts"],
});
void queryClient.invalidateQueries({ queryKey: ["kol", "prompt", variables.id] });
},
onError: (error) => message.error(formatError(error)),
onSettled: () => {
togglingPromptId.value = null;
},
});
const packageInitialValue = computed<Partial<CreateKolPackageRequest> | undefined>(() => {
if (!editingPackage.value) return undefined;
return {
@@ -135,130 +162,172 @@ function handleDeletePackage(id: number) {
});
}
function handleSelectPackage(packageId: number) {
selectedPackageId.value = packageId;
editingPromptId.value = null;
}
function handleOpenEditor(promptId: number) {
editingPromptId.value = promptId;
editorDrawerOpen.value = true;
}
function handleCloseEditor() {
editingPromptId.value = null;
}
function promptStatusLabel(status: string) {
if (status === "active") return t("kol.manage.status.active");
if (status === "archived") return t("kol.manage.status.archived");
return t("kol.manage.status.draft");
}
</script>
<template>
<div class="kol-manage-view">
<PageHero
:title="t('kol.manage.title')"
:description="t('route.tracking.description')"
/>
<div class="manage-content">
...
<KolPackageFormModal
v-model:open="packageModalOpen"
:initial-value="packageInitialValue"
@submit="handlePackageSubmit"
/>
<a-button type="primary" size="small" @click="handleCreatePackage">
<template #icon><PlusOutlined /></template>
{{ t('kol.manage.createPackage') }}
</a-button>
</div>
<a-list
class="package-list"
:loading="packagesPending"
:data-source="packages"
size="small"
>
<template #renderItem="{ item }">
<a-list-item
:class="{ 'is-selected': selectedPackageId === item.id }"
@click="selectedPackageId = item.id"
>
<div class="package-item-content">
<div class="package-name">{{ item.name }}</div>
<div class="package-meta">
<a-tag :color="item.status === 'published' ? 'green' : 'orange'" size="small">
{{ item.status }}
</a-tag>
<span>{{ item.prompt_count }} Prompts</span>
</div>
</div>
<template #actions>
<a-dropdown>
<a class="ant-dropdown-link" @click.prevent>...</a>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEditPackage(item)">
<EditOutlined /> {{ t('common.edit') }}
</a-menu-item>
<a-menu-item
v-if="item.status !== 'published'"
@click="publishPackageMutation.mutate(item.id)"
>
<CloudUploadOutlined /> {{ t('kol.manage.publishPackage') }}
</a-menu-item>
<a-menu-item
v-if="item.status === 'published'"
@click="archivePackageMutation.mutate(item.id)"
>
<InboxOutlined /> {{ t('kol.manage.archivePackage') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleDeletePackage(item.id)">
<DeleteOutlined /> {{ t('common.delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-list-item>
</template>
</a-list>
<div v-if="editingPromptId" class="manage-content manage-content--editor">
<div class="fullscreen-editor">
<KolPromptEditor :prompt-id="editingPromptId" @back="handleCloseEditor" />
</div>
<!-- Right Column: Prompts -->
<div class="prompt-column">
<template v-if="selectedPackageId">
</div>
<div v-else class="manage-content">
<div class="package-column">
<div class="column-header">
<h3>Prompts</h3>
<a-button type="primary" size="small" @click="promptModalOpen = true">
<h3>订阅包</h3>
<a-button type="primary" size="small" @click="handleCreatePackage">
<template #icon><PlusOutlined /></template>
{{ t('kol.manage.createPrompt') }}
{{ t('kol.manage.createPackage') }}
</a-button>
</div>
<a-table
:columns="[
{ title: t('common.title'), dataIndex: 'name', key: 'name' },
{ title: 'Platform', dataIndex: 'platform_hint', key: 'platform_hint' },
{ title: t('common.status'), dataIndex: 'status', key: 'status' },
{ title: t('common.actions'), key: 'actions', align: 'right' },
]"
:data-source="prompts"
:loading="promptsPending"
<a-list
class="package-list"
:loading="packagesPending"
:data-source="packages"
size="small"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'platform_hint'">
<a-tag color="blue">{{ record.platform_hint || '通用' }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : 'orange'">
{{ record.status }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<a-button type="link" size="small" @click="handleOpenEditor(record.id)">
{{ t('common.edit') }}
</a-button>
</template>
<template #renderItem="{ item }">
<a-list-item
:class="{ 'is-selected': selectedPackageId === item.id }"
@click="handleSelectPackage(item.id)"
>
<div class="package-item-content">
<div class="package-name">{{ item.name }}</div>
<div class="package-meta">
<a-tag :color="item.status === 'published' ? 'green' : 'orange'" size="small">
{{ item.status }}
</a-tag>
<span>{{ item.prompt_count }} 提示词</span>
</div>
</div>
<template #actions>
<a-dropdown>
<a class="ant-dropdown-link" @click.prevent>...</a>
<template #overlay>
<a-menu>
<a-menu-item @click="handleEditPackage(item)">
<EditOutlined /> {{ t('common.edit') }}
</a-menu-item>
<a-menu-item
v-if="item.status !== 'published'"
@click="publishPackageMutation.mutate(item.id)"
>
<CloudUploadOutlined /> {{ t('kol.manage.publishPackage') }}
</a-menu-item>
<a-menu-item
v-if="item.status === 'published'"
@click="archivePackageMutation.mutate(item.id)"
>
<InboxOutlined /> {{ t('kol.manage.archivePackage') }}
</a-menu-item>
<a-menu-divider />
<a-menu-item danger @click="handleDeletePackage(item.id)">
<DeleteOutlined /> {{ t('common.delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</a-list-item>
</template>
</a-table>
</template>
<div v-else class="empty-prompts">
<a-empty description="请选择左侧订阅包以查看 Prompt" />
</a-list>
</div>
<!-- Right Column: Prompts -->
<div class="prompt-column">
<template v-if="selectedPackageId">
<div class="column-header">
<h3>提示词</h3>
<a-button type="primary" size="small" @click="promptModalOpen = true">
<template #icon><PlusOutlined /></template>
{{ t('kol.manage.createPrompt') }}
</a-button>
</div>
<a-table
:columns="[
{ title: t('common.title'), dataIndex: 'name', key: 'name' },
{ title: t('tracking.columns.platform'), dataIndex: 'platform_hint', key: 'platform_hint' },
{ title: t('common.status'), dataIndex: 'status', key: 'status' },
{ title: t('common.actions'), key: 'actions', align: 'right', width: 112 },
]"
:data-source="prompts"
:loading="promptsPending"
size="small"
:pagination="false"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'platform_hint'">
<a-tag color="blue">{{ record.platform_hint || '通用' }}</a-tag>
</template>
<template v-else-if="column.key === 'status'">
<a-tag :color="record.status === 'active' ? 'green' : record.status === 'archived' ? 'orange' : 'default'">
{{ promptStatusLabel(record.status) }}
</a-tag>
</template>
<template v-else-if="column.key === 'actions'">
<div class="table-actions-row">
<a-tooltip :title="t('common.edit')">
<a-button
type="text"
shape="circle"
size="small"
class="action-btn action-edit"
@click="handleOpenEditor(record.id)"
>
<EditOutlined />
</a-button>
</a-tooltip>
<a-tooltip
:title="record.status === 'active' ? t('kol.manage.archivePrompt') : t('kol.manage.activatePrompt')"
>
<a-button
type="text"
shape="circle"
size="small"
:class="[
'action-btn',
record.status === 'active' ? 'action-disable' : 'action-enable',
]"
:loading="togglePromptStatusMutation.isPending.value && togglingPromptId === record.id"
@click="togglePromptStatusMutation.mutate({
id: record.id,
nextStatus: record.status === 'active' ? 'archived' : 'active',
})"
>
<PauseCircleOutlined v-if="record.status === 'active'" />
<PlayCircleOutlined v-else />
</a-button>
</a-tooltip>
</div>
</template>
</template>
</a-table>
</template>
<div v-else class="empty-prompts">
<a-empty description="请选择左侧订阅包以查看提示词" />
</div>
</div>
</div>
</div>
<KolPackageFormModal
v-model:open="packageModalOpen"
@@ -271,16 +340,6 @@ function handleOpenEditor(promptId: number) {
:package-id="selectedPackageId || 0"
@submit="createPromptMutation.mutate"
/>
<a-drawer
v-model:open="editorDrawerOpen"
width="90vw"
:closable="true"
:destroy-on-close="true"
class="editor-drawer"
>
<KolPromptEditor v-if="editingPromptId" :prompt-id="editingPromptId" />
</a-drawer>
</div>
</template>
@@ -291,13 +350,23 @@ function handleOpenEditor(promptId: number) {
height: calc(100vh - 64px);
}
.fullscreen-editor {
flex: 1;
min-height: 0;
border-radius: 12px;
overflow: hidden;
}
.manage-content--editor {
padding: 16px;
}
.manage-content {
flex: 1;
display: flex;
overflow: hidden;
padding: 16px;
gap: 16px;
background: #f5f5f5;
}
.package-column {
@@ -314,6 +383,7 @@ function handleOpenEditor(promptId: number) {
border-radius: 8px;
display: flex;
flex-direction: column;
min-width: 0;
}
.column-header {
@@ -372,7 +442,29 @@ function handleOpenEditor(promptId: number) {
justify-content: center;
}
.editor-drawer :deep(.ant-drawer-body) {
padding: 0;
.prompt-editor-panel {
flex: 1;
min-height: 0;
}
.table-actions-row {
display: inline-flex;
align-items: center;
gap: 4px;
}
.action-edit.ant-btn:hover:not(:disabled) {
color: #52c41a;
background: #f6ffed;
}
.action-enable.ant-btn:hover:not(:disabled) {
color: #1677ff;
background: #e6f4ff;
}
.action-disable.ant-btn:hover:not(:disabled) {
color: #fa8c16;
background: #fff7e6;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -1361,15 +1361,8 @@ export interface UpdateKolPromptRequest {
sort_order?: number;
}
export interface KolSaveDraftRequest {
export interface PublishKolPromptRequest extends UpdateKolPromptRequest {
content: string;
schema: KolSchemaJson;
card_config: Record<string, unknown>;
}
export interface KolPromptRevision {
id: number;
prompt_id: number;
revision_no: number;
created_at: string;
}
+1
View File
@@ -4,3 +4,4 @@ configs/config.local.yaml
*.out
vendor/
.env
.superpowers/
+57 -8
View File
@@ -23,6 +23,8 @@ import (
type seedState struct {
TenantID int64
UserID int64
TestTenantID int64
TestUserID int64
PlanID int64
BrandID int64
KeywordPrimaryID int64
@@ -129,6 +131,8 @@ func main() {
fmt.Println("Seed data inserted successfully.")
fmt.Printf(" Tenant ID: %d\n", state.TenantID)
fmt.Printf(" User ID: %d (admin@geo.local / Admin@123)\n", state.UserID)
fmt.Printf(" Test Tenant ID: %d\n", state.TestTenantID)
fmt.Printf(" Test User ID: %d (test@geo.local / Test@123)\n", state.TestUserID)
fmt.Printf(" Plan ID: %d (free)\n", state.PlanID)
fmt.Printf(" Brand ID: %d (轻氧口腔)\n", state.BrandID)
fmt.Printf(" Plugin Installation ID: %d\n", state.PluginInstallationID)
@@ -176,6 +180,10 @@ func seedBusinessData(ctx context.Context, pool *pgxpool.Pool) (*seedState, erro
if err := ensureTemplates(ctx, tx); err != nil {
return nil, err
}
state.TestTenantID, state.TestUserID, err = ensureSecondaryTenantUser(ctx, tx, state.PlanID)
if err != nil {
return nil, err
}
state.BrandID, err = ensureBrand(ctx, tx, state.TenantID)
if err != nil {
@@ -280,48 +288,89 @@ func seedMonitoringData(ctx context.Context, pool *pgxpool.Pool, state *seedStat
}
func ensureTenant(ctx context.Context, tx pgx.Tx) (int64, error) {
return ensureNamedTenant(ctx, tx, "GEO Demo")
}
func ensureNamedTenant(ctx context.Context, tx pgx.Tx, name string) (int64, error) {
var tenantID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO tenants (name, status)
VALUES ('GEO Demo', 'active')
VALUES ($1, 'active')
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id FROM tenants WHERE name = 'GEO Demo'
SELECT id FROM tenants WHERE name = $1
LIMIT 1
`).Scan(&tenantID)
`, name).Scan(&tenantID)
return tenantID, wrapSeedErr("insert tenant", err)
}
func ensureUser(ctx context.Context, tx pgx.Tx, passwordHash string) (int64, error) {
return ensureNamedUser(ctx, tx, "admin@geo.local", "Admin", passwordHash)
}
func ensureNamedUser(ctx context.Context, tx pgx.Tx, email, name, passwordHash string) (int64, error) {
var userID int64
err := tx.QueryRow(ctx, `
WITH ensured AS (
INSERT INTO users (email, password_hash, name, status)
VALUES ('admin@geo.local', $1, 'Admin', 'active')
VALUES ($1, $2, $3, 'active')
ON CONFLICT DO NOTHING
RETURNING id
)
SELECT id FROM ensured
UNION ALL
SELECT id FROM users WHERE email = 'admin@geo.local'
SELECT id FROM users WHERE email = $1
LIMIT 1
`, passwordHash).Scan(&userID)
`, email, passwordHash, name).Scan(&userID)
return userID, wrapSeedErr("insert user", err)
}
func ensureMembership(ctx context.Context, tx pgx.Tx, tenantID, userID int64) error {
return ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin")
}
func ensureMembershipWithRole(ctx context.Context, tx pgx.Tx, tenantID, userID int64, role string) error {
_, err := tx.Exec(ctx, `
INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role)
VALUES ($1, $2, 'tenant_admin')
VALUES ($1, $2, $3)
ON CONFLICT DO NOTHING
`, tenantID, userID)
`, tenantID, userID, role)
return wrapSeedErr("insert membership", err)
}
func ensureSecondaryTenantUser(ctx context.Context, tx pgx.Tx, planID int64) (int64, int64, error) {
hash, err := bcrypt.GenerateFromPassword([]byte("Test@123"), bcrypt.DefaultCost)
if err != nil {
return 0, 0, fmt.Errorf("hash secondary user password: %w", err)
}
tenantID, err := ensureNamedTenant(ctx, tx, "test")
if err != nil {
return 0, 0, err
}
userID, err := ensureNamedUser(ctx, tx, "test@geo.local", "test", string(hash))
if err != nil {
return 0, 0, err
}
if err := ensureMembershipWithRole(ctx, tx, tenantID, userID, "tenant_admin"); err != nil {
return 0, 0, err
}
if err := ensureSubscription(ctx, tx, tenantID, planID); err != nil {
return 0, 0, err
}
if err := ensureQuotaLedger(ctx, tx, tenantID); err != nil {
return 0, 0, err
}
return tenantID, userID, nil
}
func ensurePlan(ctx context.Context, tx pgx.Tx) (int64, error) {
var planID int64
err := tx.QueryRow(ctx, `
+9 -5
View File
@@ -286,14 +286,18 @@ platform_templates:
竞品名称: {{competitor_names}}
要求:
- 标题风格应契合实用选购指南、盘点观察、横向比较和决策参考等内容调性。
- 标题要求强商业属性,例子:
- 「XX 行业必看!2026 年度营销自动化平台深度盘点」
- xxx 行业口碑好
- xxxx 性价比高
- xxx 优质商家
- xxx 服务好
- 标题不出现 brand_name、competitor_names 等具体品牌或竞品名称。
- 优先体现当前年份、地域或主题线索、数量信息和核心关键词,但保持自然,不为凑字段硬拼。
- 优先体现当前年份、地域或主题线索和客户高频搜索词,但保持自然,不为凑字段硬拼。
- 标题尽量使用疑问句或总结式表达,中文标题尽量控制在 30 字以内。
- 撰写一个疑问语气的汇总类的标题,但标题中不能缺少[年份][地域][数字][关键词][行业][总结]等字段.
- 标题里一定要出现行业痛点词和客户高频搜索词
- 撰写一个疑问语气的汇总类的标题,但标题中不能缺少[年份][地域][数字][关键词][行业]等字段.
- 标题里可以出现行业痛点词。
- 标题中禁止出现的词汇有:“头部”、“首选”、“TOP”、“排行”“榜”“靠谱”“权威”“有限””背书”“医院排名”等词汇内容.
- 不要机械重复模板名称或「Top X 文章」字样。
- 每个标题应具体、可读,且角度各不相同,不写标题党。
outline_prompt_template: |
你正在为一篇推荐类文章生成实用大纲。
@@ -103,10 +103,10 @@ func (w *ScheduleDispatchWorker) run(ctx context.Context) {
}
func (w *ScheduleDispatchWorker) runOnce(parent context.Context) {
ctx, cancel := context.WithTimeout(parent, w.timeout)
defer cancel()
if count, err := w.hydrateMissingNextRuns(ctx); err != nil {
ctx, cancel := w.stageContext(parent)
count, err := w.hydrateMissingNextRuns(ctx)
cancel()
if err != nil {
if w.logger != nil {
w.logger.Warn("schedule dispatch hydrate missing next_run_at failed", zap.Error(err))
}
@@ -114,7 +114,10 @@ func (w *ScheduleDispatchWorker) runOnce(parent context.Context) {
w.logger.Info("schedule dispatch hydrated next_run_at", zap.Int("task_count", count))
}
if paused, stats, err := w.shouldPauseDispatch(ctx); err != nil {
ctx, cancel = w.stageContext(parent)
paused, stats, err := w.shouldPauseDispatch(ctx)
cancel()
if err != nil {
if w.logger != nil {
w.logger.Warn("schedule dispatch inspect generation queue failed", zap.Error(err))
}
@@ -129,7 +132,9 @@ func (w *ScheduleDispatchWorker) runOnce(parent context.Context) {
return
}
ctx, cancel = w.stageContext(parent)
tasks, err := w.claimDueSchedules(ctx)
cancel()
if err != nil {
if w.logger != nil {
w.logger.Warn("schedule dispatch claim due schedules failed", zap.Error(err))
@@ -477,6 +482,13 @@ func schedulerDispatchConcurrency(cfg config.SchedulerConfig) int {
return 1
}
func (w *ScheduleDispatchWorker) stageContext(parent context.Context) (context.Context, context.CancelFunc) {
if parent == nil {
parent = context.Background()
}
return context.WithTimeout(parent, w.timeout)
}
func (w *ScheduleDispatchWorker) invalidateScheduleTaskCaches(updates []scheduleTaskCacheUpdate) {
if w == nil || w.cache == nil || len(updates) == 0 {
return
@@ -99,16 +99,16 @@ func (s *KolGenerationService) WithCache(c sharedcache.Cache) *KolGenerationServ
}
func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor, subPromptID int64) (*KolSubscriptionPromptSchemaResponse, error) {
row, revision, err := s.loadAuthorizedRevision(ctx, actor, subPromptID)
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
if err != nil {
return nil, err
}
schema, err := decodeKolSchema(revision.SchemaJSON)
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(revision.CardConfigJSON)
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return nil, response.ErrInternal(50097, "kol_generation_card_config_decode_failed", err.Error())
}
@@ -126,7 +126,7 @@ func (s *KolGenerationService) GetSchema(ctx context.Context, actor auth.Actor,
}
func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, subPromptID int64, variables map[string]any) (*KolGenerationSubmitResponse, error) {
row, revision, err := s.loadAuthorizedRevision(ctx, actor, subPromptID)
row, prompt, err := s.loadAuthorizedPrompt(ctx, actor, subPromptID)
if err != nil {
return nil, err
}
@@ -135,12 +135,16 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
variables = map[string]any{}
}
schema, err := decodeKolSchema(revision.SchemaJSON)
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50096, "kol_generation_schema_decode_failed", err.Error())
}
content, err := s.asset.Get(ctx, revision.PromptAssetKey)
if prompt.PromptAssetKey == nil || *prompt.PromptAssetKey == "" {
return nil, response.ErrInternal(50098, "kol_generation_prompt_asset_missing", "published prompt content not found")
}
content, err := s.asset.Get(ctx, *prompt.PromptAssetKey)
if err != nil {
return nil, response.ErrInternal(50098, "kol_generation_prompt_asset_load_failed", err.Error())
}
@@ -150,6 +154,10 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
return nil, response.ErrBadRequest(40071, "kol_generation_variables_invalid", err.Error())
}
renderedPromptHash := HashPrompt(renderedPrompt)
promptRevisionNo := 1
if row.PublishedRevisionNo != nil && *row.PublishedRevisionNo > 0 {
promptRevisionNo = *row.PublishedRevisionNo
}
balance, err := s.quotaRepo.GetCurrentBalance(ctx, actor.TenantID, "article_generation")
if err != nil || balance < 1 {
@@ -161,10 +169,10 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
SubscriptionID: row.SubscriptionID,
PackageID: row.PackageID,
PromptID: row.PromptID,
PromptRevisionNo: *row.PublishedRevisionNo,
PromptRevisionNo: promptRevisionNo,
Variables: variables,
SchemaSnapshot: json.RawMessage(revision.SchemaJSON),
CardConfigSnapshot: json.RawMessage(revision.CardConfigJSON),
SchemaSnapshot: json.RawMessage(prompt.SchemaJSON),
CardConfigSnapshot: json.RawMessage(prompt.CardConfigJSON),
RenderedPromptHash: renderedPromptHash,
RenderedPrompt: renderedPrompt,
})
@@ -259,10 +267,10 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
SubscriptionPromptID: &row.ID,
PackageID: row.PackageID,
PromptID: row.PromptID,
PromptRevisionNo: *row.PublishedRevisionNo,
PromptRevisionNo: promptRevisionNo,
GenerationTaskID: &taskID,
VariablesSnapshot: variablesJSON,
SchemaSnapshot: revision.SchemaJSON,
SchemaSnapshot: prompt.SchemaJSON,
RenderedPromptHash: &renderedPromptHash,
})
if err != nil {
@@ -300,7 +308,7 @@ func (s *KolGenerationService) Submit(ctx context.Context, actor auth.Actor, sub
}, nil
}
func (s *KolGenerationService) loadAuthorizedRevision(ctx context.Context, actor auth.Actor, subPromptID int64) (*repository.KolSubscriptionPrompt, *repository.KolPromptRevision, error) {
func (s *KolGenerationService) loadAuthorizedPrompt(ctx context.Context, actor auth.Actor, subPromptID int64) (*repository.KolSubscriptionPrompt, *repository.KolPrompt, error) {
row, err := s.subRepo.GetSubscriptionPromptByID(ctx, actor.TenantID, subPromptID)
if err != nil {
return nil, nil, response.ErrInternal(50109, "kol_subscription_prompt_query_failed", err.Error())
@@ -319,19 +327,19 @@ func (s *KolGenerationService) loadAuthorizedRevision(ctx context.Context, actor
return nil, nil, ErrKolGenerationForbidden
case row.PackageStatus != "published":
return nil, nil, ErrKolGenerationForbidden
case row.PromptStatus != "active" || row.PublishedRevisionNo == nil:
case row.PromptStatus != "active":
return nil, nil, ErrKolGenerationForbidden
}
revision, err := s.promptRepo.GetRevision(ctx, row.CreatorTenantID, row.PromptID, *row.PublishedRevisionNo)
prompt, err := s.promptRepo.GetByID(ctx, row.CreatorTenantID, row.PromptID)
if err != nil {
return nil, nil, response.ErrInternal(50110, "kol_prompt_revision_query_failed", err.Error())
return nil, nil, response.ErrInternal(50110, "kol_prompt_query_failed", err.Error())
}
if revision == nil {
return nil, nil, response.ErrInternal(50111, "kol_prompt_revision_missing", "published prompt revision not found")
if prompt == nil || prompt.PromptAssetKey == nil || *prompt.PromptAssetKey == "" {
return nil, nil, response.ErrInternal(50111, "kol_prompt_content_missing", "published prompt content not found")
}
return row, revision, nil
return row, prompt, nil
}
func HandleKolGenerationTaskFailure(
+212 -103
View File
@@ -15,11 +15,11 @@ import (
)
var (
errKolPackageNotFound = response.ErrNotFound(40461, "kol_package_not_found", "package not found")
ErrPackageNotOwned = response.ErrForbidden(40361, "kol_package_not_owned", "package is not owned by current KOL")
ErrPromptNotFound = response.ErrNotFound(40462, "kol_prompt_not_found", "prompt not found")
ErrPromptNotOwned = response.ErrForbidden(40362, "kol_prompt_not_owned", "prompt is not owned by current KOL")
errKolPromptDraftEmpty = response.ErrConflict(40961, "kol_prompt_draft_missing", "no draft revision to publish")
errKolPackageNotFound = response.ErrNotFound(40461, "kol_package_not_found", "package not found")
ErrPackageNotOwned = response.ErrForbidden(40361, "kol_package_not_owned", "package is not owned by current KOL")
ErrPromptNotFound = response.ErrNotFound(40462, "kol_prompt_not_found", "prompt not found")
ErrPromptNotOwned = response.ErrForbidden(40362, "kol_prompt_not_owned", "prompt is not owned by current KOL")
errKolPromptContentEmpty = response.ErrConflict(40961, "kol_prompt_content_missing", "prompt content is required before publish or activate")
)
type CreatePromptInput struct {
@@ -34,11 +34,13 @@ type UpdateKolPromptInput struct {
SortOrder *int `json:"sort_order"`
}
type SaveDraftInput struct {
PromptID int64 `json:"-"`
Content string `json:"content" binding:"required"`
Schema KolSchemaJSON `json:"schema" binding:"required"`
CardConfig map[string]interface{} `json:"card_config"`
type PublishPromptInput struct {
Name string `json:"name" binding:"required"`
PlatformHint *string `json:"platform_hint"`
SortOrder *int `json:"sort_order"`
Content string `json:"content" binding:"required"`
Schema KolSchemaJSON `json:"schema" binding:"required"`
CardConfig map[string]interface{} `json:"card_config"`
}
type KolPromptDetailResponse struct {
@@ -57,13 +59,6 @@ type KolPromptDetailResponse struct {
UpdatedAt time.Time `json:"updated_at"`
}
type KolPromptRevisionResponse struct {
ID int64 `json:"id"`
PromptID int64 `json:"prompt_id"`
RevisionNo int `json:"revision_no"`
CreatedAt time.Time `json:"created_at"`
}
type KolPromptService struct {
pool *pgxpool.Pool
profileSvc *KolProfileService
@@ -184,7 +179,16 @@ func (s *KolPromptService) UpdateMetadata(ctx context.Context, actor auth.Actor,
return s.buildPromptDetail(ctx, updated, false)
}
func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, input SaveDraftInput) (*KolPromptRevisionResponse, error) {
func (s *KolPromptService) Save(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*KolPromptDetailResponse, error) {
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, promptID)
if err != nil {
return nil, err
}
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
}
if strings.TrimSpace(input.Content) == "" {
return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required")
}
@@ -192,17 +196,86 @@ func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, inpu
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
}
_, prompt, _, err := s.loadOwnedPrompt(ctx, actor, input.PromptID)
sortOrder := prompt.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
const currentRevisionNo = 1
assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, currentRevisionNo, input.Content)
if err != nil {
return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error())
}
schemaJSON, err := JSONEncodeSchema(input.Schema)
if err != nil {
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
}
cardConfigJSON, err := json.Marshal(normalizeKolCardConfig(input.CardConfig))
if err != nil {
return nil, response.ErrBadRequest(40066, "kol_prompt_card_config_invalid", "card_config must be valid json")
}
publishedRevisionNo := prompt.PublishedRevisionNo
if prompt.Status == "active" {
revNo := currentRevisionNo
publishedRevisionNo = &revNo
}
if err := s.promptRepo.Save(ctx, repository.SaveKolPromptInput{
ID: promptID,
TenantID: actor.TenantID,
Name: name,
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
SortOrder: sortOrder,
PromptAssetKey: assetKey,
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
Status: prompt.Status,
PublishedRevisionNo: publishedRevisionNo,
UpdatedBy: actor.UserID,
}); err != nil {
return nil, response.ErrInternal(50072, "kol_prompt_update_failed", err.Error())
}
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
if err != nil {
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
}
if updated == nil {
return nil, ErrPromptNotFound
}
return s.buildPromptDetail(ctx, updated, true)
}
func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64, input PublishPromptInput) (*KolPromptDetailResponse, error) {
_, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID)
if err != nil {
return nil, err
}
nextRevisionNo, err := s.promptRepo.NextRevisionNo(ctx, actor.TenantID, input.PromptID)
if err != nil {
return nil, response.ErrInternal(50073, "kol_prompt_revision_query_failed", err.Error())
name := strings.TrimSpace(input.Name)
if name == "" {
return nil, response.ErrBadRequest(40063, "kol_prompt_name_required", "prompt name is required")
}
if strings.TrimSpace(input.Content) == "" {
return nil, response.ErrBadRequest(40064, "kol_prompt_content_required", "prompt content is required")
}
if err := ValidateSchema(input.Schema); err != nil {
return nil, response.ErrBadRequest(40065, "kol_prompt_schema_invalid", err.Error())
}
assetKey, err := s.asset.Put(ctx, actor.TenantID, input.PromptID, nextRevisionNo, input.Content)
sortOrder := prompt.SortOrder
if input.SortOrder != nil {
sortOrder = *input.SortOrder
}
const publishedRevisionNo = 1
assetKey, err := s.asset.Put(ctx, actor.TenantID, promptID, publishedRevisionNo, input.Content)
if err != nil {
return nil, response.ErrInternal(50074, "kol_prompt_asset_store_failed", err.Error())
}
@@ -219,82 +292,108 @@ func (s *KolPromptService) SaveDraft(ctx context.Context, actor auth.Actor, inpu
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50075, "kol_prompt_draft_tx_failed", "failed to begin prompt draft transaction")
}
defer tx.Rollback(ctx)
promptTx := repository.NewKolPromptRepository(tx)
revision, err := promptTx.CreateRevision(ctx, repository.CreateKolPromptRevisionInput{
TenantID: actor.TenantID,
PromptID: prompt.ID,
RevisionNo: nextRevisionNo,
PromptAssetKey: assetKey,
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
CreatedBy: actor.UserID,
})
if err != nil {
return nil, response.ErrInternal(50076, "kol_prompt_draft_create_failed", err.Error())
}
if err := promptTx.SetDraftRevision(ctx, actor.TenantID, prompt.ID, nextRevisionNo, actor.UserID); err != nil {
return nil, response.ErrInternal(50077, "kol_prompt_draft_update_failed", err.Error())
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50078, "kol_prompt_draft_commit_failed", err.Error())
}
return &KolPromptRevisionResponse{
ID: revision.ID,
PromptID: revision.PromptID,
RevisionNo: revision.RevisionNo,
CreatedAt: revision.CreatedAt,
}, nil
}
func (s *KolPromptService) Publish(ctx context.Context, actor auth.Actor, promptID int64) error {
_, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID)
if err != nil {
return err
}
if prompt.DraftRevisionNo == nil {
return errKolPromptDraftEmpty
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction")
return nil, response.ErrInternal(50079, "kol_prompt_publish_tx_failed", "failed to begin prompt publish transaction")
}
defer tx.Rollback(ctx)
promptTx := repository.NewKolPromptRepository(tx)
subTx := repository.NewKolSubscriptionRepository(tx)
if err := promptTx.SetPublishedRevision(ctx, actor.TenantID, promptID, *prompt.DraftRevisionNo, actor.UserID); err != nil {
return response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error())
if err := promptTx.Publish(ctx, repository.PublishKolPromptInput{
ID: promptID,
TenantID: actor.TenantID,
Name: name,
PlatformHint: normalizeKolOptionalString(input.PlatformHint),
SortOrder: sortOrder,
PromptAssetKey: assetKey,
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
PublishedRevisionNo: publishedRevisionNo,
UpdatedBy: actor.UserID,
}); err != nil {
return nil, response.ErrInternal(50080, "kol_prompt_publish_failed", err.Error())
}
subscribers, err := subTx.ListActiveSubscribersForPackage(ctx, pkg.ID)
if err != nil {
return response.ErrInternal(50083, "kol_subscription_query_failed", err.Error())
}
for _, sub := range subscribers {
if err := subTx.CreateAccessRow(ctx, repository.CreateAccessRowInput{
TenantID: sub.TenantID,
SubscriptionID: sub.ID,
PackageID: pkg.ID,
PromptID: promptID,
}); err != nil {
return response.ErrInternal(50084, "kol_subscription_access_create_failed", err.Error())
}
if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error())
return nil, response.ErrInternal(50081, "kol_prompt_publish_commit_failed", err.Error())
}
return nil
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
if err != nil {
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
}
if updated == nil {
return nil, ErrPromptNotFound
}
return s.buildPromptDetail(ctx, updated, true)
}
func (s *KolPromptService) Activate(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
_, prompt, pkg, err := s.loadOwnedPrompt(ctx, actor, promptID)
if err != nil {
return nil, err
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, response.ErrInternal(50088, "kol_prompt_activate_tx_failed", "failed to begin prompt activate transaction")
}
defer tx.Rollback(ctx)
promptTx := repository.NewKolPromptRepository(tx)
subTx := repository.NewKolSubscriptionRepository(tx)
switch {
case prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "":
if err := promptTx.SetPublishedRevision(ctx, actor.TenantID, promptID, 1, actor.UserID); err != nil {
return nil, response.ErrInternal(50090, "kol_prompt_activate_failed", err.Error())
}
default:
return nil, errKolPromptContentEmpty
}
if err := s.grantPromptToActiveSubscribers(ctx, subTx, pkg.ID, promptID); err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, response.ErrInternal(50091, "kol_prompt_activate_commit_failed", "failed to commit prompt activate transaction")
}
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
if err != nil {
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
}
if updated == nil {
return nil, ErrPromptNotFound
}
return s.buildPromptDetail(ctx, updated, false)
}
func (s *KolPromptService) Archive(ctx context.Context, actor auth.Actor, promptID int64) (*KolPromptDetailResponse, error) {
if _, _, _, err := s.loadOwnedPrompt(ctx, actor, promptID); err != nil {
return nil, err
}
if err := s.promptRepo.UpdateStatus(ctx, actor.TenantID, promptID, "archived", actor.UserID); err != nil {
return nil, response.ErrInternal(50092, "kol_prompt_archive_failed", err.Error())
}
updated, err := s.promptRepo.GetByID(ctx, actor.TenantID, promptID)
if err != nil {
return nil, response.ErrInternal(50071, "kol_prompt_query_failed", err.Error())
}
if updated == nil {
return nil, ErrPromptNotFound
}
return s.buildPromptDetail(ctx, updated, false)
}
func (s *KolPromptService) Delete(ctx context.Context, actor auth.Actor, promptID int64) error {
@@ -371,27 +470,15 @@ func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *reposi
UpdatedAt: prompt.UpdatedAt,
}
revisionNo := prompt.DraftRevisionNo
if revisionNo == nil {
revisionNo = prompt.PublishedRevisionNo
}
if revisionNo == nil {
if len(prompt.SchemaJSON) == 0 && len(prompt.CardConfigJSON) == 0 && (prompt.PromptAssetKey == nil || strings.TrimSpace(*prompt.PromptAssetKey) == "") {
return detail, nil
}
revision, err := s.promptRepo.GetRevision(ctx, prompt.TenantID, prompt.ID, *revisionNo)
if err != nil {
return nil, response.ErrInternal(50083, "kol_prompt_revision_query_failed", err.Error())
}
if revision == nil {
return nil, response.ErrInternal(50084, "kol_prompt_revision_not_found", "prompt revision not found")
}
schema, err := decodeKolSchema(revision.SchemaJSON)
schema, err := decodeKolSchema(prompt.SchemaJSON)
if err != nil {
return nil, response.ErrInternal(50085, "kol_prompt_schema_decode_failed", err.Error())
}
cardConfig, err := decodeKolCardConfig(revision.CardConfigJSON)
cardConfig, err := decodeKolCardConfig(prompt.CardConfigJSON)
if err != nil {
return nil, response.ErrInternal(50086, "kol_prompt_card_config_decode_failed", err.Error())
}
@@ -399,8 +486,8 @@ func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *reposi
detail.LatestSchemaJSON = schema
detail.LatestCardConfigJSON = cardConfig
if includeContent {
content, err := s.asset.Get(ctx, revision.PromptAssetKey)
if includeContent && prompt.PromptAssetKey != nil && strings.TrimSpace(*prompt.PromptAssetKey) != "" {
content, err := s.asset.Get(ctx, *prompt.PromptAssetKey)
if err != nil {
return nil, response.ErrInternal(50087, "kol_prompt_asset_load_failed", err.Error())
}
@@ -410,6 +497,28 @@ func (s *KolPromptService) buildPromptDetail(ctx context.Context, prompt *reposi
return detail, nil
}
func (s *KolPromptService) grantPromptToActiveSubscribers(
ctx context.Context,
subRepo repository.KolSubscriptionRepository,
packageID, promptID int64,
) error {
subscribers, err := subRepo.ListActiveSubscribersForPackage(ctx, packageID)
if err != nil {
return response.ErrInternal(50083, "kol_subscription_query_failed", err.Error())
}
for _, sub := range subscribers {
if err := subRepo.CreateAccessRow(ctx, repository.CreateAccessRowInput{
TenantID: sub.TenantID,
SubscriptionID: sub.ID,
PackageID: packageID,
PromptID: promptID,
}); err != nil {
return response.ErrInternal(50084, "kol_subscription_access_create_failed", err.Error())
}
}
return nil
}
func normalizeKolCardConfig(cardConfig map[string]interface{}) map[string]interface{} {
if cardConfig == nil {
return map[string]interface{}{}
@@ -33,14 +33,21 @@ type KolSchemaJSON struct {
Variables []KolVariableDefinition `json:"variables"`
}
var kolPlaceholderRE = regexp.MustCompile(`\{\{\s*([^}\s]+)\s*\}\}`)
var kolPlaceholderRE = regexp.MustCompile(`\{\{\s*([^{}]+?)\s*\}\}`)
func RenderPrompt(content string, schema KolSchemaJSON, values map[string]any) (string, error) {
schema = normalizeKolSchema(schema)
byID := make(map[string]KolVariableDefinition, len(schema.Variables))
byToken := make(map[string]KolVariableDefinition, len(schema.Variables)*2)
for _, variable := range schema.Variables {
byID[variable.ID] = variable
id := strings.TrimSpace(variable.ID)
key := strings.TrimSpace(variable.Key)
if id != "" {
byToken[id] = variable
}
if key != "" {
byToken[key] = variable
}
}
missingSet := make(map[string]struct{})
@@ -62,19 +69,19 @@ func RenderPrompt(content string, schema KolSchemaJSON, values map[string]any) (
return match
}
id := submatches[1]
variable, exists := byID[id]
token := strings.TrimSpace(submatches[1])
variable, exists := byToken[token]
if !exists {
addMissing(id)
addMissing(token)
return match
}
value, hasValue := values[id]
value, hasValue := lookupKolVariableValue(values, variable, token)
if hasValue {
return fmt.Sprint(value)
}
if variable.Required {
addMissing(id)
addMissing(kolVariableDisplayName(variable))
}
return ""
})
@@ -94,16 +101,26 @@ func HashPrompt(rendered string) string {
func ValidateSchema(schema KolSchemaJSON) error {
schema = normalizeKolSchema(schema)
seen := make(map[string]struct{}, len(schema.Variables))
seenIDs := make(map[string]struct{}, len(schema.Variables))
seenKeys := make(map[string]struct{}, len(schema.Variables))
for _, variable := range schema.Variables {
id := strings.TrimSpace(variable.ID)
if id == "" || !strings.HasPrefix(id, "var_") {
return fmt.Errorf("variable id must be non-empty and start with var_")
}
if _, exists := seen[id]; exists {
if _, exists := seenIDs[id]; exists {
return fmt.Errorf("duplicate variable id: %s", id)
}
seen[id] = struct{}{}
seenIDs[id] = struct{}{}
key := strings.TrimSpace(variable.Key)
if key == "" {
return fmt.Errorf("variable key must be non-empty")
}
if _, exists := seenKeys[key]; exists {
return fmt.Errorf("duplicate variable key: %s", key)
}
seenKeys[key] = struct{}{}
switch KolVariableType(strings.TrimSpace(string(variable.Type))) {
case KolVariableTypeInput, KolVariableTypeTextarea, KolVariableTypeSelect, KolVariableTypeNumber, KolVariableTypeCheckbox:
@@ -125,3 +142,38 @@ func normalizeKolSchema(schema KolSchemaJSON) KolSchemaJSON {
}
return schema
}
func lookupKolVariableValue(values map[string]any, variable KolVariableDefinition, token string) (any, bool) {
candidates := []string{
strings.TrimSpace(token),
strings.TrimSpace(variable.Key),
strings.TrimSpace(variable.ID),
}
seen := make(map[string]struct{}, len(candidates))
for _, candidate := range candidates {
if candidate == "" {
continue
}
if _, exists := seen[candidate]; exists {
continue
}
seen[candidate] = struct{}{}
if value, ok := values[candidate]; ok {
return value, true
}
}
return nil, false
}
func kolVariableDisplayName(variable KolVariableDefinition) string {
if label := strings.TrimSpace(variable.Label); label != "" {
return label
}
if key := strings.TrimSpace(variable.Key); key != "" {
return key
}
return strings.TrimSpace(variable.ID)
}
@@ -22,8 +22,8 @@ func TestKolVariableRenderPrompt(t *testing.T) {
content: "Hello {{var_a}} from {{var_b}}",
schema: KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_a", Type: KolVariableTypeInput, Required: true},
{ID: "var_b", Type: KolVariableTypeSelect},
{ID: "var_a", Key: "brand", Type: KolVariableTypeInput, Required: true},
{ID: "var_b", Key: "industry", Type: KolVariableTypeSelect},
},
},
values: map[string]any{
@@ -32,12 +32,38 @@ func TestKolVariableRenderPrompt(t *testing.T) {
},
want: "Hello 宜家 from 家具",
},
{
name: "supports key placeholders and key values",
content: "围绕 {{目标词}} 写一篇文章,并突出 {{目标词}} 的优势",
schema: KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_a", Key: "目标词", Label: "目标词", Type: KolVariableTypeInput, Required: true},
},
},
values: map[string]any{
"目标词": "北京装修公司",
},
want: "围绕 北京装修公司 写一篇文章,并突出 北京装修公司 的优势",
},
{
name: "supports key placeholders with id values",
content: "Hello {{brand}}",
schema: KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_a", Key: "brand", Label: "Brand", Type: KolVariableTypeInput, Required: true},
},
},
values: map[string]any{
"var_a": "宜家",
},
want: "Hello 宜家",
},
{
name: "missing required",
content: "Hello {{var_required}}",
schema: KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_required", Type: KolVariableTypeInput, Required: true},
{ID: "var_required", Key: "required", Label: "Required", Type: KolVariableTypeInput, Required: true},
},
},
values: map[string]any{},
@@ -75,8 +101,8 @@ func TestKolVariableValidateSchemaRejectsDuplicateID(t *testing.T) {
err := ValidateSchema(KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_a", Type: KolVariableTypeInput},
{ID: "var_a", Type: KolVariableTypeSelect},
{ID: "var_a", Key: "first", Type: KolVariableTypeInput},
{ID: "var_a", Key: "second", Type: KolVariableTypeSelect},
},
})
@@ -89,10 +115,24 @@ func TestKolVariableValidateSchemaRejectsInvalidID(t *testing.T) {
err := ValidateSchema(KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "bad_id", Type: KolVariableTypeInput},
{ID: "bad_id", Key: "bad", Type: KolVariableTypeInput},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "start with var_")
}
func TestKolVariableValidateSchemaRejectsDuplicateKey(t *testing.T) {
t.Parallel()
err := ValidateSchema(KolSchemaJSON{
Variables: []KolVariableDefinition{
{ID: "var_a", Key: "topic", Type: KolVariableTypeInput},
{ID: "var_b", Key: "topic", Type: KolVariableTypeSelect},
},
})
require.Error(t, err)
require.Contains(t, err.Error(), "duplicate variable key")
}
@@ -21,24 +21,15 @@ type KolPrompt struct {
SortOrder int
PublishedRevisionNo *int
DraftRevisionNo *int
PromptAssetKey *string
SchemaJSON []byte
CardConfigJSON []byte
CreatedBy int64
UpdatedBy int64
CreatedAt time.Time
UpdatedAt time.Time
}
type KolPromptRevision struct {
ID int64
TenantID int64
PromptID int64
RevisionNo int
PromptAssetKey string
SchemaJSON []byte
CardConfigJSON []byte
CreatedBy int64
CreatedAt time.Time
}
type ActiveKolPrompt struct {
ID int64
TenantID int64
@@ -64,14 +55,31 @@ type UpdateKolPromptMetadataInput struct {
UpdatedBy int64
}
type CreateKolPromptRevisionInput struct {
TenantID int64
PromptID int64
RevisionNo int
PromptAssetKey string
SchemaJSON []byte
CardConfigJSON []byte
CreatedBy int64
type SaveKolPromptInput struct {
ID int64
TenantID int64
Name string
PlatformHint *string
SortOrder int
PromptAssetKey string
SchemaJSON []byte
CardConfigJSON []byte
Status string
PublishedRevisionNo *int
UpdatedBy int64
}
type PublishKolPromptInput struct {
ID int64
TenantID int64
Name string
PlatformHint *string
SortOrder int
PromptAssetKey string
SchemaJSON []byte
CardConfigJSON []byte
PublishedRevisionNo int
UpdatedBy int64
}
type KolPromptRepository interface {
@@ -80,41 +88,121 @@ type KolPromptRepository interface {
ListByPackage(ctx context.Context, tenantID, packageID int64) ([]KolPrompt, error)
ListActiveByPackage(ctx context.Context, tenantID, packageID int64) ([]ActiveKolPrompt, error)
UpdateMetadata(ctx context.Context, input UpdateKolPromptMetadataInput) error
SetDraftRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error
Save(ctx context.Context, input SaveKolPromptInput) error
Publish(ctx context.Context, input PublishKolPromptInput) error
SetPublishedRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error
UpdateStatus(ctx context.Context, tenantID, id int64, status string, updatedBy int64) error
SoftDelete(ctx context.Context, tenantID, id int64) error
CreateRevision(ctx context.Context, input CreateKolPromptRevisionInput) (*KolPromptRevision, error)
GetRevision(ctx context.Context, tenantID, promptID int64, revNo int) (*KolPromptRevision, error)
NextRevisionNo(ctx context.Context, tenantID, promptID int64) (int, error)
}
type kolPromptRepository struct {
q generated.Querier
db generated.DBTX
q generated.Querier
}
func NewKolPromptRepository(db generated.DBTX) KolPromptRepository {
return &kolPromptRepository{q: newQuerier(db)}
return &kolPromptRepository{db: db, q: newQuerier(db)}
}
func mapKolPrompt(row generated.GetKolPromptByIDRow) *KolPrompt {
func mapKolPromptRow(
id int64,
tenantID int64,
packageID int64,
name string,
platformHint pgtype.Text,
status string,
sortOrder int32,
publishedRevisionNo pgtype.Int4,
draftRevisionNo pgtype.Int4,
promptAssetKey pgtype.Text,
schemaJSON []byte,
cardConfigJSON []byte,
createdBy int64,
updatedBy int64,
createdAt pgtype.Timestamptz,
updatedAt pgtype.Timestamptz,
) *KolPrompt {
return &KolPrompt{
ID: row.ID,
TenantID: row.TenantID,
PackageID: row.PackageID,
Name: row.Name,
PlatformHint: nullableText(row.PlatformHint),
Status: row.Status,
SortOrder: int(row.SortOrder),
PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo),
DraftRevisionNo: nullableIntFromInt4(row.DraftRevisionNo),
CreatedBy: row.CreatedBy,
UpdatedBy: row.UpdatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
ID: id,
TenantID: tenantID,
PackageID: packageID,
Name: name,
PlatformHint: nullableText(platformHint),
Status: status,
SortOrder: int(sortOrder),
PublishedRevisionNo: nullableIntFromInt4(publishedRevisionNo),
DraftRevisionNo: nullableIntFromInt4(draftRevisionNo),
PromptAssetKey: nullableText(promptAssetKey),
SchemaJSON: schemaJSON,
CardConfigJSON: cardConfigJSON,
CreatedBy: createdBy,
UpdatedBy: updatedBy,
CreatedAt: timeFromTimestamp(createdAt),
UpdatedAt: timeFromTimestamp(updatedAt),
}
}
func scanKolPrompt(scanner interface{ Scan(...any) error }) (*KolPrompt, error) {
var (
id int64
tenantID int64
packageID int64
name string
platformHint pgtype.Text
status string
sortOrder int32
publishedRevisionNo pgtype.Int4
draftRevisionNo pgtype.Int4
promptAssetKey pgtype.Text
schemaJSON []byte
cardConfigJSON []byte
createdBy int64
updatedBy int64
createdAt pgtype.Timestamptz
updatedAt pgtype.Timestamptz
)
if err := scanner.Scan(
&id,
&tenantID,
&packageID,
&name,
&platformHint,
&status,
&sortOrder,
&publishedRevisionNo,
&draftRevisionNo,
&promptAssetKey,
&schemaJSON,
&cardConfigJSON,
&createdBy,
&updatedBy,
&createdAt,
&updatedAt,
); err != nil {
return nil, err
}
return mapKolPromptRow(
id,
tenantID,
packageID,
name,
platformHint,
status,
sortOrder,
publishedRevisionNo,
draftRevisionNo,
promptAssetKey,
schemaJSON,
cardConfigJSON,
createdBy,
updatedBy,
createdAt,
updatedAt,
), nil
}
func (r *kolPromptRepository) Create(ctx context.Context, input CreateKolPromptInput) (*KolPrompt, error) {
row, err := r.q.CreateKolPrompt(ctx, generated.CreateKolPromptParams{
TenantID: input.TenantID,
@@ -127,62 +215,56 @@ func (r *kolPromptRepository) Create(ctx context.Context, input CreateKolPromptI
if err != nil {
return nil, err
}
return &KolPrompt{
ID: row.ID,
TenantID: row.TenantID,
PackageID: row.PackageID,
Name: row.Name,
PlatformHint: nullableText(row.PlatformHint),
Status: row.Status,
SortOrder: int(row.SortOrder),
PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo),
DraftRevisionNo: nullableIntFromInt4(row.DraftRevisionNo),
CreatedBy: row.CreatedBy,
UpdatedBy: row.UpdatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
}, nil
return r.GetByID(ctx, input.TenantID, row.ID)
}
func (r *kolPromptRepository) GetByID(ctx context.Context, tenantID, id int64) (*KolPrompt, error) {
row, err := r.q.GetKolPromptByID(ctx, generated.GetKolPromptByIDParams{
ID: id,
TenantID: tenantID,
})
row := r.db.QueryRow(ctx, `
SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order,
published_revision_no, draft_revision_no, prompt_asset_key, schema_json, card_config_json,
created_by, updated_by, created_at, updated_at
FROM kol_prompts
WHERE id = $1
AND tenant_id = $2
AND deleted_at IS NULL
`, id, tenantID)
prompt, err := scanKolPrompt(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return mapKolPrompt(row), nil
return prompt, nil
}
func (r *kolPromptRepository) ListByPackage(ctx context.Context, tenantID, packageID int64) ([]KolPrompt, error) {
rows, err := r.q.ListKolPromptsByPackage(ctx, generated.ListKolPromptsByPackageParams{
PackageID: packageID,
TenantID: tenantID,
})
rows, err := r.db.Query(ctx, `
SELECT id, tenant_id, package_id, name, platform_hint, status, sort_order,
published_revision_no, draft_revision_no, prompt_asset_key, schema_json, card_config_json,
created_by, updated_by, created_at, updated_at
FROM kol_prompts
WHERE package_id = $1
AND tenant_id = $2
AND deleted_at IS NULL
ORDER BY sort_order ASC, created_at ASC
`, packageID, tenantID)
if err != nil {
return nil, err
}
result := make([]KolPrompt, len(rows))
for i, row := range rows {
result[i] = KolPrompt{
ID: row.ID,
TenantID: row.TenantID,
PackageID: row.PackageID,
Name: row.Name,
PlatformHint: nullableText(row.PlatformHint),
Status: row.Status,
SortOrder: int(row.SortOrder),
PublishedRevisionNo: nullableIntFromInt4(row.PublishedRevisionNo),
DraftRevisionNo: nullableIntFromInt4(row.DraftRevisionNo),
CreatedBy: row.CreatedBy,
UpdatedBy: row.UpdatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
UpdatedAt: timeFromTimestamp(row.UpdatedAt),
defer rows.Close()
result := make([]KolPrompt, 0)
for rows.Next() {
prompt, err := scanKolPrompt(rows)
if err != nil {
return nil, err
}
result = append(result, *prompt)
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
@@ -218,13 +300,50 @@ func (r *kolPromptRepository) UpdateMetadata(ctx context.Context, input UpdateKo
})
}
func (r *kolPromptRepository) SetDraftRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error {
return r.q.UpdateKolPromptDraftRevision(ctx, generated.UpdateKolPromptDraftRevisionParams{
RevisionNo: pgtype.Int4{Int32: int32(revNo), Valid: true},
UpdatedBy: updatedBy,
ID: promptID,
TenantID: tenantID,
})
func (r *kolPromptRepository) Save(ctx context.Context, input SaveKolPromptInput) error {
publishedRevisionNo := pgtype.Int4{}
if input.PublishedRevisionNo != nil {
publishedRevisionNo = pgtype.Int4{Int32: int32(*input.PublishedRevisionNo), Valid: true}
}
_, err := r.db.Exec(ctx, `
UPDATE kol_prompts
SET name = $1,
platform_hint = $2,
sort_order = $3,
prompt_asset_key = $4,
schema_json = $5::jsonb,
card_config_json = $6::jsonb,
status = $7,
published_revision_no = $8,
updated_by = $9,
updated_at = NOW()
WHERE id = $10
AND tenant_id = $11
AND deleted_at IS NULL
`, input.Name, pgText(input.PlatformHint), int32(input.SortOrder), input.PromptAssetKey, input.SchemaJSON, input.CardConfigJSON, input.Status, publishedRevisionNo, input.UpdatedBy, input.ID, input.TenantID)
return err
}
func (r *kolPromptRepository) Publish(ctx context.Context, input PublishKolPromptInput) error {
_, err := r.db.Exec(ctx, `
UPDATE kol_prompts
SET name = $1,
platform_hint = $2,
sort_order = $3,
prompt_asset_key = $4,
schema_json = $5::jsonb,
card_config_json = $6::jsonb,
published_revision_no = $7,
draft_revision_no = NULL,
status = 'active',
updated_by = $8,
updated_at = NOW()
WHERE id = $9
AND tenant_id = $10
AND deleted_at IS NULL
`, input.Name, pgText(input.PlatformHint), int32(input.SortOrder), input.PromptAssetKey, input.SchemaJSON, input.CardConfigJSON, input.PublishedRevisionNo, input.UpdatedBy, input.ID, input.TenantID)
return err
}
func (r *kolPromptRepository) SetPublishedRevision(ctx context.Context, tenantID, promptID int64, revNo int, updatedBy int64) error {
@@ -252,68 +371,6 @@ func (r *kolPromptRepository) SoftDelete(ctx context.Context, tenantID, id int64
})
}
func (r *kolPromptRepository) CreateRevision(ctx context.Context, input CreateKolPromptRevisionInput) (*KolPromptRevision, error) {
row, err := r.q.CreateKolPromptRevision(ctx, generated.CreateKolPromptRevisionParams{
TenantID: input.TenantID,
PromptID: input.PromptID,
RevisionNo: int32(input.RevisionNo),
PromptAssetKey: input.PromptAssetKey,
SchemaJson: input.SchemaJSON,
CardConfigJson: input.CardConfigJSON,
CreatedBy: input.CreatedBy,
})
if err != nil {
return nil, err
}
return &KolPromptRevision{
ID: row.ID,
TenantID: input.TenantID,
PromptID: row.PromptID,
RevisionNo: int(row.RevisionNo),
PromptAssetKey: row.PromptAssetKey,
SchemaJSON: row.SchemaJson,
CardConfigJSON: row.CardConfigJson,
CreatedBy: row.CreatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
}, nil
}
func (r *kolPromptRepository) GetRevision(ctx context.Context, tenantID, promptID int64, revNo int) (*KolPromptRevision, error) {
row, err := r.q.GetKolPromptRevision(ctx, generated.GetKolPromptRevisionParams{
PromptID: promptID,
RevisionNo: int32(revNo),
TenantID: tenantID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &KolPromptRevision{
ID: row.ID,
TenantID: tenantID,
PromptID: row.PromptID,
RevisionNo: int(row.RevisionNo),
PromptAssetKey: row.PromptAssetKey,
SchemaJSON: row.SchemaJson,
CardConfigJSON: row.CardConfigJson,
CreatedBy: row.CreatedBy,
CreatedAt: timeFromTimestamp(row.CreatedAt),
}, nil
}
func (r *kolPromptRepository) NextRevisionNo(ctx context.Context, tenantID, promptID int64) (int, error) {
next, err := r.q.NextKolPromptRevisionNo(ctx, generated.NextKolPromptRevisionNoParams{
PromptID: promptID,
TenantID: tenantID,
})
if err != nil {
return 0, err
}
return int(next), nil
}
func nullableIntFromInt4(value pgtype.Int4) *int {
if !value.Valid {
return nil
@@ -277,25 +277,24 @@ func (h *KolManageHandler) DeletePrompt(c *gin.Context) {
response.Success(c, nil)
}
func (h *KolManageHandler) SaveDraft(c *gin.Context) {
func (h *KolManageHandler) SavePrompt(c *gin.Context) {
promptID, ok := parseInt64Param(c, "id", "prompt id")
if !ok {
return
}
var req app.SaveDraftInput
var req app.PublishPromptInput
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
req.PromptID = promptID
actor, ok := actorFromRequest(c)
if !ok {
return
}
data, err := h.promptSvc.SaveDraft(c.Request.Context(), actor, req)
data, err := h.promptSvc.Save(c.Request.Context(), actor, promptID, req)
if err != nil {
response.Error(c, err)
return
@@ -310,17 +309,64 @@ func (h *KolManageHandler) PublishPrompt(c *gin.Context) {
return
}
var req app.PublishPromptInput
if err := c.ShouldBindJSON(&req); err != nil {
response.Error(c, response.ErrBadRequest(40001, "invalid_params", err.Error()))
return
}
actor, ok := actorFromRequest(c)
if !ok {
return
}
if err := h.promptSvc.Publish(c.Request.Context(), actor, promptID); err != nil {
data, err := h.promptSvc.Publish(c.Request.Context(), actor, promptID, req)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, gin.H{"ok": true})
response.Success(c, data)
}
func (h *KolManageHandler) ActivatePrompt(c *gin.Context) {
promptID, ok := parseInt64Param(c, "id", "prompt id")
if !ok {
return
}
actor, ok := actorFromRequest(c)
if !ok {
return
}
data, err := h.promptSvc.Activate(c.Request.Context(), actor, promptID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *KolManageHandler) ArchivePrompt(c *gin.Context) {
promptID, ok := parseInt64Param(c, "id", "prompt id")
if !ok {
return
}
actor, ok := actorFromRequest(c)
if !ok {
return
}
data, err := h.promptSvc.Archive(c.Request.Context(), actor, promptID)
if err != nil {
response.Error(c, err)
return
}
response.Success(c, data)
}
func (h *KolManageHandler) AssistSubmit(c *gin.Context) {
+3 -1
View File
@@ -70,8 +70,10 @@ func RegisterRoutes(app *bootstrap.App) {
kolManage.GET("/prompts/:id", kolManageHandler.GetPromptLatest)
kolManage.PUT("/prompts/:id", kolManageHandler.UpdatePromptMetadata)
kolManage.DELETE("/prompts/:id", kolManageHandler.DeletePrompt)
kolManage.POST("/prompts/:id/draft", kolManageHandler.SaveDraft)
kolManage.POST("/prompts/:id/save", kolManageHandler.SavePrompt)
kolManage.POST("/prompts/:id/publish", kolManageHandler.PublishPrompt)
kolManage.PUT("/prompts/:id/activate", kolManageHandler.ActivatePrompt)
kolManage.PUT("/prompts/:id/archive", kolManageHandler.ArchivePrompt)
kolManage.POST("/assist", kolManageHandler.AssistSubmit)
kolManage.GET("/assist/:id", kolManageHandler.AssistGet)
@@ -0,0 +1,12 @@
ALTER TABLE kol_usage_logs
DROP CONSTRAINT IF EXISTS fk_kol_usage_logs_prompt_revision;
ALTER TABLE kol_usage_logs
ADD CONSTRAINT fk_kol_usage_logs_prompt_revision
FOREIGN KEY (prompt_id, prompt_revision_no)
REFERENCES kol_prompt_revisions(prompt_id, revision_no);
ALTER TABLE kol_prompts
DROP COLUMN IF EXISTS card_config_json,
DROP COLUMN IF EXISTS schema_json,
DROP COLUMN IF EXISTS prompt_asset_key;
@@ -0,0 +1,37 @@
ALTER TABLE kol_prompts
ADD COLUMN IF NOT EXISTS prompt_asset_key TEXT,
ADD COLUMN IF NOT EXISTS schema_json JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN IF NOT EXISTS card_config_json JSONB NOT NULL DEFAULT '{}'::jsonb;
WITH preferred_revisions AS (
SELECT DISTINCT ON (p.id)
p.id AS prompt_id,
pr.prompt_asset_key,
pr.schema_json,
pr.card_config_json
FROM kol_prompts p
JOIN kol_prompt_revisions pr ON pr.prompt_id = p.id
ORDER BY p.id,
CASE
WHEN p.published_revision_no IS NOT NULL AND pr.revision_no = p.published_revision_no THEN 0
WHEN p.draft_revision_no IS NOT NULL AND pr.revision_no = p.draft_revision_no THEN 1
ELSE 2
END,
pr.revision_no DESC
)
UPDATE kol_prompts p
SET prompt_asset_key = r.prompt_asset_key,
schema_json = COALESCE(r.schema_json, '{}'::jsonb),
card_config_json = COALESCE(r.card_config_json, '{}'::jsonb)
FROM preferred_revisions r
WHERE p.id = r.prompt_id
AND (p.prompt_asset_key IS NULL OR btrim(p.prompt_asset_key) = '');
UPDATE kol_prompts
SET published_revision_no = 1,
draft_revision_no = NULL
WHERE prompt_asset_key IS NOT NULL
AND btrim(prompt_asset_key) <> '';
ALTER TABLE kol_usage_logs
DROP CONSTRAINT IF EXISTS fk_kol_usage_logs_prompt_revision;