feat(kol): expand variable schema and polish KOL UI

- Add length/range limits to KOL variable definitions: max_length and
  default_value for input/textarea, min_value/max_value/default_number
  for number. Backend validates and normalizes; frontend renders limit
  inputs in KolVariableConfig and applies limits at runtime in
  KolDynamicForm.
- Sort workspace cards by most recent prompt/package update, falling
  back to granted_at. Adds updated_at to KolWorkspaceCard.
- Polish TemplatesView, WorkspaceView, and KOL package management UI;
  route create/update/publish/archive/delete toasts through i18n and
  expand package form copy and status labels.
- Remove stale KnowledgeView.vue.patch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:01:40 +08:00
parent 1dd316a34b
commit 79c65c1da7
26 changed files with 1506 additions and 235 deletions
@@ -1,7 +1,12 @@
<script setup lang="ts">
import type { KolVariableDefinition } from "@geo/shared-types";
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import {
getKolVariableNumberMax,
getKolVariableNumberMin,
getKolVariableTextMaxLength,
sanitizeKolVariableValue,
} from "@/lib/kol-placeholders";
const props = defineProps<{
variables: KolVariableDefinition[];
@@ -14,13 +19,8 @@ const emit = defineEmits<{
const { t } = useI18n();
const formModel = computed({
get: () => props.modelValue,
set: (val) => emit("update:modelValue", val),
});
function updateField(key: string, value: any) {
const next = { ...props.modelValue, [key]: value };
function updateField(variable: KolVariableDefinition, key: string, value: any) {
const next = { ...props.modelValue, [key]: sanitizeKolVariableValue(variable, value) };
emit("update:modelValue", next);
}
@@ -31,6 +31,18 @@ const selectOptions = (variable: KolVariableDefinition) => {
function fieldKey(variable: KolVariableDefinition): string {
return variable.key?.trim() || variable.id;
}
function textLimit(variable: KolVariableDefinition): number {
return getKolVariableTextMaxLength(variable);
}
function numberMin(variable: KolVariableDefinition): number {
return getKolVariableNumberMin(variable);
}
function numberMax(variable: KolVariableDefinition): number {
return getKolVariableNumberMax(variable);
}
</script>
<template>
@@ -45,7 +57,9 @@ function fieldKey(variable: KolVariableDefinition): string {
<a-input
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.inputPlease')"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
:maxlength="textLimit(variable)"
show-count
@update:value="(val: string) => updateField(variable, fieldKey(variable), val)"
/>
</template>
@@ -53,8 +67,10 @@ function fieldKey(variable: KolVariableDefinition): string {
<a-textarea
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.inputPlease')"
:maxlength="textLimit(variable)"
:rows="4"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
show-count
@update:value="(val: string) => updateField(variable, fieldKey(variable), val)"
/>
</template>
@@ -63,7 +79,7 @@ function fieldKey(variable: KolVariableDefinition): string {
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder || t('common.selectPlease')"
:options="selectOptions(variable)"
@update:value="(val: string) => updateField(fieldKey(variable), val)"
@update:value="(val: string) => updateField(variable, fieldKey(variable), val)"
/>
</template>
@@ -71,8 +87,10 @@ function fieldKey(variable: KolVariableDefinition): string {
<a-input-number
:value="modelValue[fieldKey(variable)]"
:placeholder="variable.placeholder"
:min="numberMin(variable)"
:max="numberMax(variable)"
style="width: 100%"
@update:value="(val: number) => updateField(fieldKey(variable), val)"
@update:value="(val: number | null) => updateField(variable, fieldKey(variable), val)"
/>
</template>
@@ -80,7 +98,7 @@ function fieldKey(variable: KolVariableDefinition): string {
<a-checkbox-group
:value="modelValue[fieldKey(variable)] || []"
:options="selectOptions(variable)"
@update:value="(val: string[]) => updateField(fieldKey(variable), val)"
@update:value="(val: string[]) => updateField(variable, fieldKey(variable), val)"
/>
</template>
</a-form-item>
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { computed } from "vue";
import { UserOutlined, TagOutlined, TeamOutlined, ThunderboltOutlined } from "@ant-design/icons-vue";
import type { KolPackageSummary } from "@geo/shared-types";
import { useI18n } from "vue-i18n";
import { resolveApiURL } from "@/lib/api";
const props = defineProps<{
package: KolPackageSummary;
}>();
@@ -12,6 +15,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const coverUrl = computed(() => resolveApiURL(props.package.cover_url));
function handleClick() {
emit("click", props.package.id);
@@ -21,8 +25,8 @@ function handleClick() {
<template>
<a-card hoverable class="kol-package-card" @click="handleClick">
<template #cover>
<div v-if="props.package.cover_url" class="card-cover-image">
<img alt="cover" :src="props.package.cover_url" />
<div v-if="coverUrl" class="card-cover-image">
<img alt="cover" :src="coverUrl" />
</div>
<div v-else class="card-cover-fallback">
<ThunderboltOutlined class="fallback-icon" />
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { reactive, watch } from "vue";
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
import { message } from "ant-design-vue";
import { computed, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n";
import type { CreateKolPackageRequest } from "@geo/shared-types";
import CoverPickerModal from "@/components/CoverPickerModal.vue";
import { imagesApi, resolveApiURL } from "@/lib/api";
import { deriveCoverFileName } from "@/lib/cover-requirements";
const props = defineProps<{
open: boolean;
initialValue?: Partial<CreateKolPackageRequest>;
@@ -14,6 +20,7 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const coverPickerOpen = ref(false);
const formState = reactive<CreateKolPackageRequest>({
name: "",
@@ -32,29 +39,57 @@ watch(
name: props.initialValue?.name || "",
description: props.initialValue?.description || "",
industry: props.initialValue?.industry || "",
tags: props.initialValue?.tags || [],
tags: [...(props.initialValue?.tags || [])],
price_note: props.initialValue?.price_note || "",
cover_url: props.initialValue?.cover_url || "",
});
return;
}
}
coverPickerOpen.value = false;
},
);
const coverPreviewUrl = computed(() => resolveApiURL(formState.cover_url));
const coverFileName = computed(() => deriveCoverFileName(formState.cover_url));
function handleOk() {
if (!formState.name.trim()) return;
emit("submit", { ...formState });
if (!formState.name.trim()) {
message.warning(t("kol.manage.packageForm.nameRequired"));
return;
}
emit("submit", { ...formState, tags: [...formState.tags] });
emit("update:open", false);
}
function handleCancel() {
emit("update:open", false);
}
function handleRemoveCover() {
formState.cover_url = "";
}
function handleCoverConfirmed(payload: { url: string }) {
formState.cover_url = resolveApiURL(payload.url);
}
async function uploadCover(file: File) {
const uploaded = await imagesApi.upload(file);
return {
url: uploaded.url,
fileName: uploaded.name,
assetId: uploaded.id,
};
}
</script>
<template>
<a-modal
:open="open"
:title="initialValue ? t('kol.manage.editPackage') : t('kol.manage.createPackage')"
:ok-text="t('common.confirm')"
:cancel-text="t('common.cancel')"
@ok="handleOk"
@cancel="handleCancel"
>
@@ -71,17 +106,132 @@ function handleCancel() {
<a-input v-model:value="formState.industry" />
</a-form-item>
<a-form-item label="Tags">
<a-select v-model:value="formState.tags" mode="tags" style="width: 100%" placeholder="Add tags" />
<a-form-item :label="t('kol.manage.packageForm.tags')">
<a-select
v-model:value="formState.tags"
mode="tags"
style="width: 100%"
:placeholder="t('kol.manage.packageForm.tagsPlaceholder')"
/>
</a-form-item>
<a-form-item label="Price Note">
<a-input v-model:value="formState.price_note" />
<a-form-item :label="t('kol.manage.packageForm.priceNote')">
<a-input
v-model:value="formState.price_note"
:placeholder="t('kol.manage.packageForm.priceNotePlaceholder')"
/>
</a-form-item>
<a-form-item label="Cover URL">
<a-input v-model:value="formState.cover_url" />
<a-form-item :label="t('kol.manage.packageForm.cover')">
<div class="package-cover-field">
<button
type="button"
class="package-cover-picker"
@click="coverPickerOpen = true"
>
<template v-if="coverPreviewUrl">
<img :src="coverPreviewUrl" :alt="t('kol.manage.packageForm.cover')" class="package-cover-picker__image" />
</template>
<template v-else>
<div class="package-cover-picker__empty">
<PictureOutlined class="package-cover-picker__icon" />
<strong>{{ t("kol.manage.packageForm.selectCover") }}</strong>
<span>{{ t("kol.manage.packageForm.coverHint") }}</span>
</div>
</template>
</button>
<div class="package-cover-actions">
<a-button @click="coverPickerOpen = true">
{{ coverPreviewUrl ? t("kol.manage.packageForm.changeCover") : t("kol.manage.packageForm.selectCover") }}
</a-button>
<a-button v-if="coverPreviewUrl" danger @click="handleRemoveCover">
<template #icon><DeleteOutlined /></template>
{{ t("kol.manage.packageForm.removeCover") }}
</a-button>
</div>
</div>
</a-form-item>
</a-form>
</a-modal>
<CoverPickerModal
v-model:open="coverPickerOpen"
:article-id="null"
:platform-ids="[]"
:current-url="coverPreviewUrl"
:current-file-name="coverFileName"
:title="t('kol.manage.packageForm.coverPicker.title')"
:local-tab-label="t('kol.manage.packageForm.coverPicker.localTab')"
:empty-upload-title="t('kol.manage.packageForm.coverPicker.emptyTitle')"
:empty-upload-hint="t('kol.manage.packageForm.coverPicker.emptyHint')"
:upload-handler="uploadCover"
@confirmed="handleCoverConfirmed"
/>
</template>
<style scoped>
.package-cover-field {
display: flex;
flex-direction: column;
gap: 12px;
}
.package-cover-picker {
width: 100%;
min-height: 180px;
border: 1px dashed #d0d5dd;
border-radius: 12px;
padding: 0;
overflow: hidden;
background: #f8fafc;
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.package-cover-picker:hover {
border-color: #1677ff;
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.08);
}
.package-cover-picker__image {
display: block;
width: 100%;
height: 180px;
object-fit: cover;
}
.package-cover-picker__empty {
display: flex;
min-height: 180px;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 20px;
color: #475467;
text-align: center;
}
.package-cover-picker__empty strong {
color: #111827;
font-size: 15px;
font-weight: 700;
}
.package-cover-picker__empty span {
max-width: 320px;
font-size: 12px;
line-height: 1.6;
}
.package-cover-picker__icon {
font-size: 28px;
color: #1677ff;
}
.package-cover-actions {
display: flex;
gap: 8px;
}
</style>
@@ -6,7 +6,7 @@ import { message } from "ant-design-vue";
import { EditOutlined, UserOutlined, LoadingOutlined } from "@ant-design/icons-vue";
import type { KolProfile } from "@geo/shared-types";
import { kolManageApi } from "@/lib/api";
import { kolManageApi, resolveApiURL } from "@/lib/api";
import { formatError } from "@/lib/errors";
const props = defineProps<{
@@ -22,7 +22,7 @@ const draftBio = ref(props.profile.bio ?? "");
const avatarUploading = ref(false);
const fileInput = ref<HTMLInputElement | null>(null);
const avatarURL = computed(() => props.profile.avatar_url ?? "");
const avatarURL = computed(() => resolveApiURL(props.profile.avatar_url));
const updateMutation = useMutation({
mutationFn: kolManageApi.updateProfile,
@@ -14,7 +14,11 @@ import type { KolCardConfig, KolVariableDefinition } from "@geo/shared-types";
import { formatError } from "@/lib/errors";
import { buildKolPlatformOptions } from "@/lib/kol-platform-options";
import { mergeKolCardConfig, parseKolCardConfig } from "@/lib/kol-card-config";
import { syncKolVariablesWithContent } from "@/lib/kol-placeholders";
import {
getKolVariableInitialValue,
normalizeKolVariableDefinition,
syncKolVariablesWithContent,
} from "@/lib/kol-placeholders";
const props = defineProps<{
promptId: number;
@@ -69,7 +73,9 @@ watch(
metadataForm.allow_web_search = parsedCardConfig.allow_web_search;
metadataForm.allow_user_knowledge = parsedCardConfig.allow_user_knowledge;
content.value = detail.latest_prompt_content ?? "";
variables.value = detail.latest_schema_json?.variables ?? [];
variables.value = (detail.latest_schema_json?.variables ?? []).map((variable) =>
normalizeKolVariableDefinition(variable),
);
cardConfig.value = { ...nextCardConfig };
}
},
@@ -178,7 +184,7 @@ function buildPreviewValues(nextVariables: KolVariableDefinition[]): Record<stri
nextVariables.forEach((variable) => {
const fieldKey = variable.key?.trim() || variable.id;
nextValues[fieldKey] = variable.type === "checkbox" ? [] : undefined;
nextValues[fieldKey] = getKolVariableInitialValue(variable);
});
return nextValues;
@@ -4,6 +4,15 @@ 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";
import {
DEFAULT_KOL_NUMBER_MAX,
DEFAULT_KOL_NUMBER_MIN,
DEFAULT_KOL_TEXT_MAX_LENGTH,
getKolVariableNumberMax,
getKolVariableNumberMin,
getKolVariableTextMaxLength,
normalizeKolVariableDefinition,
} from "@/lib/kol-placeholders";
const props = defineProps<{
variables: KolVariableDefinition[];
@@ -33,7 +42,10 @@ type FocusVariableOptions = {
function updateVariable(index: number, updates: Partial<KolVariableDefinition>) {
const newVariables = [...props.variables];
newVariables[index] = { ...newVariables[index], ...updates };
newVariables[index] = normalizeKolVariableDefinition({
...newVariables[index],
...updates,
});
emit("update:variables", newVariables);
}
@@ -55,6 +67,73 @@ function handleTypeChange(index: number, type: KolVariableType) {
updateVariable(index, { type });
}
function handleTextMaxLengthChange(index: number, value: number | null) {
const maxLength =
typeof value === "number" && Number.isFinite(value) && value > 0
? Math.trunc(value)
: DEFAULT_KOL_TEXT_MAX_LENGTH;
const currentVariable = normalizeKolVariableDefinition(props.variables[index]);
updateVariable(index, {
max_length: maxLength,
default_value: currentVariable.default_value?.slice(0, maxLength),
});
}
function handleNumberMinChange(index: number, value: number | null) {
const currentVariable = normalizeKolVariableDefinition(props.variables[index]);
const minValue =
typeof value === "number" && Number.isFinite(value) ? value : DEFAULT_KOL_NUMBER_MIN;
const maxValue = Math.max(getKolVariableNumberMax(currentVariable), minValue);
const defaultNumber =
currentVariable.default_number === undefined
? undefined
: Math.min(maxValue, Math.max(minValue, currentVariable.default_number));
updateVariable(index, {
min_value: minValue,
max_value: maxValue,
default_number: defaultNumber,
});
}
function handleNumberMaxChange(index: number, value: number | null) {
const currentVariable = normalizeKolVariableDefinition(props.variables[index]);
const minValue = getKolVariableNumberMin(currentVariable);
const maxValue =
typeof value === "number" && Number.isFinite(value)
? Math.max(value, minValue)
: Math.max(DEFAULT_KOL_NUMBER_MAX, minValue);
const defaultNumber =
currentVariable.default_number === undefined
? undefined
: Math.min(maxValue, Math.max(minValue, currentVariable.default_number));
updateVariable(index, {
min_value: minValue,
max_value: maxValue,
default_number: defaultNumber,
});
}
function handleDefaultNumberChange(index: number, value: number | null) {
updateVariable(index, {
default_number: value === null ? undefined : value,
});
}
function textLimit(variable: KolVariableDefinition): number {
return getKolVariableTextMaxLength(variable);
}
function numberMin(variable: KolVariableDefinition): number {
return getKolVariableNumberMin(variable);
}
function numberMax(variable: KolVariableDefinition): number {
return getKolVariableNumberMax(variable);
}
function setCardRef(key: string, el: Element | ComponentPublicInstance | null) {
const element =
el instanceof HTMLElement
@@ -180,6 +259,41 @@ defineExpose({
/>
</div>
<template v-if="variable.type === 'input' || variable.type === 'textarea'">
<div class="form-item">
<label>{{ t('kol.manage.variable.maxLength') }}</label>
<a-input-number
:value="textLimit(variable)"
size="small"
:min="1"
:precision="0"
style="width: 100%"
@update:value="(val: number | null) => handleTextMaxLengthChange(index, val)"
/>
</div>
<div class="form-item">
<label>{{ t('kol.manage.variable.defaultValue') }}</label>
<a-input
v-if="variable.type === 'input'"
:value="variable.default_value"
size="small"
:maxlength="textLimit(variable)"
show-count
@input="(e: Event) => updateVariable(index, { default_value: (e.target as HTMLInputElement).value })"
/>
<a-textarea
v-else
:value="variable.default_value"
size="small"
:maxlength="textLimit(variable)"
:rows="3"
show-count
@update:value="(val: string) => updateVariable(index, { default_value: val })"
/>
</div>
</template>
<div v-if="variable.type === 'select' || variable.type === 'checkbox'" class="form-item">
<label>{{ t('kol.manage.variable.options') }}</label>
<a-input
@@ -189,6 +303,42 @@ defineExpose({
/>
</div>
<template v-if="variable.type === 'number'">
<div class="form-item-grid">
<div class="form-item">
<label>{{ t('kol.manage.variable.minValue') }}</label>
<a-input-number
:value="numberMin(variable)"
size="small"
style="width: 100%"
@update:value="(val: number | null) => handleNumberMinChange(index, val)"
/>
</div>
<div class="form-item">
<label>{{ t('kol.manage.variable.maxValue') }}</label>
<a-input-number
:value="numberMax(variable)"
size="small"
style="width: 100%"
@update:value="(val: number | null) => handleNumberMaxChange(index, val)"
/>
</div>
</div>
<div class="form-item">
<label>{{ t('kol.manage.variable.defaultNumber') }}</label>
<a-input-number
:value="variable.default_number"
size="small"
style="width: 100%"
:min="numberMin(variable)"
:max="numberMax(variable)"
@update:value="(val: number | null) => handleDefaultNumberChange(index, val)"
/>
</div>
</template>
<div class="token-display">
<code>&#123;&#123;{{ variable.key || variable.id }}&#125;&#125;</code>
</div>
@@ -274,6 +424,12 @@ defineExpose({
color: #8c8c8c;
}
.form-item-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.form-item-row {
display: flex;
justify-content: space-between;